From 1891f5d6a76e2146b0c4d147eab28ad654bb48bb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 12 Jul 2026 23:52:23 -0400 Subject: [PATCH] docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in place, each with a prior-finding status table): all 4 round-1 Criticals verified closed; new top findings are the S7 connect-timeout OCE regression (05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated maturity matrix + 12-item action list. Adds R2-01..R2-12 design/implementation plans (one per action item, house format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks, ~18-24 dev-days). STATUS.md updated: round-1 topology marked historical (all merged+pushed), re-review findings table + plan pointers added. --- archreview/00-OVERALL.md | 140 ++-- archreview/01-core-composition.md | 509 ++++++++---- archreview/02-scripting-alarms.md | 136 +++- archreview/03-server-runtime.md | 195 +++-- archreview/04-adminui.md | 124 ++- archreview/05-protocol-drivers.md | 418 ++++++---- archreview/06-gateway-integrations.md | 145 +++- archreview/07-client-tooling-engineering.md | 595 +++++++------- archreview/plans/00-INDEX.md | 38 +- .../plans/R2-01-s7-fault-hardening-plan.md | 732 ++++++++++++++++++ ...2-01-s7-fault-hardening-plan.md.tasks.json | 84 ++ .../R2-02-resilience-config-hardening-plan.md | 353 +++++++++ ...lience-config-hardening-plan.md.tasks.json | 25 + .../plans/R2-03-vt-failure-quality-plan.md | 675 ++++++++++++++++ ...2-03-vt-failure-quality-plan.md.tasks.json | 78 ++ .../plans/R2-04-failure-visibility-plan.md | 539 +++++++++++++ ...2-04-failure-visibility-plan.md.tasks.json | 21 + archreview/plans/R2-05-adminui-authz-plan.md | 575 ++++++++++++++ .../R2-05-adminui-authz-plan.md.tasks.json | 22 + .../R2-06-serverhistorian-failfast-plan.md | 247 ++++++ ...erverhistorian-failfast-plan.md.tasks.json | 78 ++ .../plans/R2-07-surgical-pure-adds-plan.md | 371 +++++++++ ...2-07-surgical-pure-adds-plan.md.tasks.json | 96 +++ .../R2-08-ldap-historyread-async-plan.md | 676 ++++++++++++++++ ...-ldap-historyread-async-plan.md.tasks.json | 20 + .../plans/R2-09-driver-fleet-batch-plan.md | 571 ++++++++++++++ ...2-09-driver-fleet-batch-plan.md.tasks.json | 41 + .../R2-10-resilience-observability-plan.md | 193 +++++ ...esilience-observability-plan.md.tasks.json | 17 + .../R2-11-tagconfig-consolidation-plan.md | 496 ++++++++++++ ...tagconfig-consolidation-plan.md.tasks.json | 30 + archreview/plans/R2-12-repo-hygiene-plan.md | 547 +++++++++++++ .../R2-12-repo-hygiene-plan.md.tasks.json | 19 + archreview/plans/STATUS.md | 57 +- 34 files changed, 7962 insertions(+), 901 deletions(-) create mode 100644 archreview/plans/R2-01-s7-fault-hardening-plan.md create mode 100644 archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json create mode 100644 archreview/plans/R2-02-resilience-config-hardening-plan.md create mode 100644 archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json create mode 100644 archreview/plans/R2-03-vt-failure-quality-plan.md create mode 100644 archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json create mode 100644 archreview/plans/R2-04-failure-visibility-plan.md create mode 100644 archreview/plans/R2-04-failure-visibility-plan.md.tasks.json create mode 100644 archreview/plans/R2-05-adminui-authz-plan.md create mode 100644 archreview/plans/R2-05-adminui-authz-plan.md.tasks.json create mode 100644 archreview/plans/R2-06-serverhistorian-failfast-plan.md create mode 100644 archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json create mode 100644 archreview/plans/R2-07-surgical-pure-adds-plan.md create mode 100644 archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json create mode 100644 archreview/plans/R2-08-ldap-historyread-async-plan.md create mode 100644 archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json create mode 100644 archreview/plans/R2-09-driver-fleet-batch-plan.md create mode 100644 archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json create mode 100644 archreview/plans/R2-10-resilience-observability-plan.md create mode 100644 archreview/plans/R2-10-resilience-observability-plan.md.tasks.json create mode 100644 archreview/plans/R2-11-tagconfig-consolidation-plan.md create mode 100644 archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json create mode 100644 archreview/plans/R2-12-repo-hygiene-plan.md create mode 100644 archreview/plans/R2-12-repo-hygiene-plan.md.tasks.json diff --git a/archreview/00-OVERALL.md b/archreview/00-OVERALL.md index 0115dbcc..3f78cf73 100644 --- a/archreview/00-OVERALL.md +++ b/archreview/00-OVERALL.md @@ -1,8 +1,10 @@ # OtOpcUa — Deep Architecture Review: Overall Report -**Date:** 2026-07-08 -**Commit:** `9cad9ed0` (master, clean tree) -**Method:** Seven parallel domain reviews, each scoring the same four dimensions (stability, performance, conventions, underdeveloped areas) with severity-rated, file:line-referenced findings. This document is the synthesis; the detail lives in the per-domain reports. +**Date:** 2026-07-12 +**Commit:** `f6eaa267` (master, clean tree) +**Method:** Seven parallel domain re-reviews, same scopes and four dimensions (stability, performance, conventions, underdeveloped areas) as the original. This is the **first update** of the 2026-07-08 review at `9cad9ed0`: every prior finding was re-verified against code (not against remediation claims), and each domain got a fresh-eyes pass with extra scrutiny on the newly merged remediation code itself. Per-domain reports carry a `Prior-finding status (9cad9ed0 → f6eaa267)` table; remediation history is in [`plans/STATUS.md`](plans/STATUS.md). + +**Delta reviewed:** all 11 arch-review remediation branches merged and pushed to `origin/master` (4 Criticals, 3 systemic guards, #10 CapabilityInvoker dispatch wiring, #13 ResilienceConfig artifact plumbing, #11 Crit-1 premise correction, docs branch, docker-dev rig fix), plus the fixdocs XML-doc sweep (`3b5ef439`) and the HistorianGateway Client/Contracts 0.1.0→0.2.0 bump (`f6eaa267`). 84 files, +9218/−217. ## Report index @@ -16,106 +18,94 @@ | 06 | [Gateway integrations](06-gateway-integrations.md) | Driver.Galaxy(+Browser,+Contracts), Driver.Historian.Gateway | | 07 | [Client, tooling & engineering system](07-client-tooling-engineering.md) | Client.CLI/Shared/UI, Analyzers, build/CPM, CI, test architecture, repo hygiene | -## Consolidated maturity matrix (1–5) +## Consolidated maturity matrix (1–5, previous score in parentheses) | Subsystem | Stability | Performance | Conventions | Underdeveloped | |---|---|---|---|---| -| 01 Core & composition | 4 | 3 | 4 | 3 | -| 02 Scripting & alarms | 3.5 | 3 | 4 | **2** | -| 03 Server & runtime | 3 | 3 | 4 | 3 | -| 04 AdminUI | 4 | 3 | 3 | 3 | -| 05 Protocol drivers | 3 | **2** | 3 | 3 | -| 06 Gateway integrations | 4 | 4 | 4 | 3 | -| 07 Client & engineering | **2.5** | 3.5 | 3 | 3 | +| 01 Core & composition | 4 (4) | 3 (3) | 4 (4) | 3 (3) | +| 02 Scripting & alarms | 3.5 (3.5) | 3 (3) | 4 (4) | 3 (**2**) | +| 03 Server & runtime | 3 (3) | 3 (3) | 4 (4) | 4 (3) | +| 04 AdminUI | 4 (4) | 3 (3) | 3 (3) | 3 (3) | +| 05 Protocol drivers | 3 (3) | **2** (2) | 3 (3) | 3 (3) | +| 06 Gateway integrations | 4 (4) | 4 (4) | 4 (4) | 3 (3) | +| 07 Client & engineering | 3.5 (**2.5**) | 3.5 (3.5) | 4 (3) | 3 (3) | -**Read of the matrix:** the system's *design* is strong — conventions score 3–4 everywhere, layering is disciplined (Akka-free OpcUaServer, SDK-free Runtime, clean gateway seams), and there is essentially zero TODO/stub debt. The weaknesses cluster in *operational* stability (failover, reconnect, timeout paths that only fire under fault) and in the *verification system* (CI proves little; distributed and fault-injection behavior is outside the test net). This is the profile of a codebase built by careful review + live-verification rather than by automated gates — it works, but its correctness under failure is largely unproven. +**Read of the matrix:** the remediation round did exactly what it targeted. The verification system moved most (07 stability 2.5→3.5, conventions 3→4: whole-solution CI, fail-on-skip, tree-wide analyzer — all verified live with negative controls), and the two engine gaps closed (02 underdeveloped 2→3; 03 underdeveloped 3→4 via the failover test + forwarding guard). Nothing regressed at the matrix level. What kept scores from rising further: the untouched High backlog (failure visibility, AdminUI authz, driver perf/concurrency) and **new second-order findings inside the remediation code itself** — the sharpest of which are one High regression (S7) and one operator-authorable driver-brick (ResilienceConfig). --- -## Critical findings (fix first) +## Status of the original four Criticals -These four can each take down or silently corrupt production data flow, and none is caught by any existing test. +| # | Original Critical | Verdict at `f6eaa267` | +|---|---|---| +| 1 | Split-brain resolver never activated | **Premise corrected (#11) + closed** — SBR was already active on master (Akka.Cluster.Hosting applies `SplitBrainResolverOption.Default` → pre-existing akka.conf keep-oldest). Now explicit in code (`KeepOldestOption{DownIfAlone=true}`) and guarded by the hard-kill failover integration test (`HardKillFailoverTests`, runs in CI). | +| 2 | VT script timeout ineffective + ALC cache leak | **Fixed** (`7fd44f0f`) — production path routes through `TimedScriptEvaluator`; `CompiledScriptCache` + apply-boundary clear. Residuals: new 02/S13 (failure/timeout leaves the node stale-Good, below) and 02/S12 (clear-vs-in-flight race). | +| 3 | S7 no reconnect | **Fixed** (`25c0c6f6`, live-verified vs python-snap7) — but the fix carries a **new High regression, 05/STAB-14** (below). | +| 4 | TwinCAT orphaned ADS subscriptions | **Fixed** (`af318fb4`, fake-client verified; no live fixture exists) — residual 05/STAB-16 (replay failure is log-only). | -1. **Split-brain resolver is configured but never activated** — the SBR block exists in `akka.conf:40-46`, but nothing sets `ClusterOptions.SplitBrainResolver` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs:80-84`), so Akka runs with default NoDowning. A hard-crashed node is never downed: **cluster singletons and the driver role-leader never fail over on a hard crash**, and a partition leaves both redundancy sides advertising ServiceLevel 240 indefinitely. Invisible to the 2-node harness because it only exercises graceful stops. *(Report 03, S1)* +## Critical/High findings — updated ranking -2. **Production virtual-tag script timeout is ineffective** — `Host/Engines/RoslynVirtualTagEvaluator.cs:102-106` calls `ScriptEvaluator.RunAsync` directly, bypassing `TimedScriptEvaluator` (which exists and is tested). Script execution is synchronous and only checks the token at entry, so one user-authored infinite loop hangs the owning `VirtualTagActor` forever. *(Report 02, U2)* +**New in this round (all inside or activated by the remediation delta):** -3. **S7 driver has no reconnect path** — the PLC connection is opened only in `InitializeAsync` (`Driver.S7/S7Driver.cs:165-167`); the probe detects the outage but nothing repairs it. A PLC reboot permanently kills the driver instance until redeploy. Zero S7 reconnect tests. *(Report 05)* +1. **05/STAB-14 (High, regression)** — S7's ensure-wrapper rethrows connect-*timeout* `OperationCanceledException` unconditionally, and `PollLoopAsync`'s bare `catch (OCE) { return; }` then **permanently, silently kills every S7 subscription poll** after an unreachable-host outage (PLC dead-to-SYN, not refusing). The live bounce test can't see it — `docker restart` yields connection-refused, not timeout. Two-line filter fix + a fake-factory connect-timeout test (`S7Driver.cs:488, 1000, 1383, 1404, 1206-1208`). +2. **01/S-6 (High)** — a valid-JSON `ResilienceConfig` with `timeoutSeconds: 0` (or out-of-range) passes the parser but makes Polly throw `ValidationException` at pipeline build **on every capability call** — an operator-authorable permanent driver-brick (the AdminUI form has no range validation), violating the parser's explicit never-brick contract (`DriverResilienceOptionsParser.cs:91-95`, `DriverResiliencePipelineBuilder.cs:111-114`, `ResilienceFormModel.cs:32-37`). +3. **05/STAB-15 (High)** — S7's `IsS7ConnectionFatal` misses framing/desync errors and cancellation-during-I/O (half-read PDU kept → permanent stream desync — the Modbus STAB-3 failure mode reproduced), and the probe catch never marks the handle dead (`S7Driver.cs:1239-1251, 1542`). +4. **03/S12 = 01/S-8 (Medium, two reviewers independently)** — write dispatch hardcodes `isIdempotent: true`; `WriteIdempotentAttribute` has zero readers, so the moment an operator enables Write retries via #13, **command-shaped writes get retried** (`DriverInstanceActor.cs:597-601`). +5. **02/S13 (Medium)** — the timeout fix converted a visible hang into an invisible fault: a script that starts failing/timing out never degrades node quality, leaving the last Good value **stale-Good forever** (`VirtualTagActor.cs:123-128`; the dormant engine maps to BadInternalError, the live path doesn't). +6. **01/U-6 (Medium)** — the resilience **bulkhead is a fully dead knob**: documented, defaulted, parsed, and authorable in AdminUI, but `Build` never adds a concurrency limiter (`DriverResiliencePipelineBuilder.cs:99-177`). +7. **06/S-11 (Medium)** — an enabled-but-misconfigured `ServerHistorian` still crash-loops the Host with a raw `UriFormatException` (proven live in the #12 rig session; only the config side was fixed) (`HistorianGatewayClientAdapter.cs:45`). +8. **06/U-7 (Medium)** — the 0.2.0 bump's proto3-optional `HistorianTagDefinition.DataType` change silently alters `GatewayTagProvisioner` semantics (Boolean was wire-0 → provisioned as Float under 0.1.0, honored as Int1 under 0.2.0); pre-bump tags mismatch and the live EnsureTags leg has never run on 0.2.0 (`GatewayTagProvisioner.cs:57`). +9. **04/C-7 + 04/U-5 + 03/U10 (Medium)** — the resilience authoring/observability surface is half-built now that #10/#13 made it production-live: the AdminUI form round-trip is lossy (malformed JSON silently overwritten, unknown keys stripped), and `DriverResilienceStatusTracker` has **no reader anywhere** (Admin `/hosts` Stream E.2/E.3 never shipped) — operators author live retry/breaker config blind. -4. **TwinCAT native ADS subscriptions are orphaned after reconnect** — the default subscription mode is never re-registered on the replacement client after reconnect (`TwinCATDriver.cs:615-619`, `AdsTwinCATClient.cs:422-436`); data silently stops with no Bad status. *(Report 05)* +**Carried open from 2026-07-08 (re-verified, no remediation branch targeted them):** -## High-severity findings (system-wide risk ranking) - -**Data-integrity / failure-visibility group** — the system tends to report success it hasn't earned: - -- **Deploy applier swallows sink failures**: a deploy whose `RebuildAddressSpace()` threw still reports success to the deploy coordinator; `AddressSpaceApplyOutcome` has no failure field (`AddressSpaceApplier.cs:150-153, 373-383`). *(01, S-1)* -- **Galaxy write success is optimistic**: empty status arrays translate to Good, and a failed supervisory-advise still lets the write "proceed" though it can never reach the Galaxy — write-outcome self-correction structurally cannot fire for Galaxy (`GatewayGalaxyDataWriter.cs:163-169, 234-243, 281-302`). *(06, S-1)* -- **Primary gate defaults to allow while role is unknown** (`DriverHostActor.cs:1018-1026`): a booting secondary services field-device writes for up to a 10 s window; compounds with at-most-once DPS delivery. *(03, S4)* -- **Modbus transport desyncs permanently after a response timeout** — timeout/TxId-mismatch aren't classified socket-fatal, leaving the single-flight stream misaligned (`ModbusTcpTransport.cs:155-165, 233-235`). *(05)* -- **AbCip races concurrent ops on shared libplctag handles with no lock** (`AbCipDriver.cs:544-575`) — torn values with Good status; AbLegacy guards this exact hazard. FOCAS has the sibling bug on plain-Dictionary caches. *(05)* - -**Availability / blocking group:** - -- **LDAP authentication block-bridges OPC UA session activation** with an unconfigurable 10 s connect timeout and no pooling (`OpcUaApplicationHost.cs:271-273`) — an LDAP outage serially stalls SDK threads. *(03, S2)* -- **HistoryRead block-bridges per node sequentially** on SDK request threads bounded only by the 30 s gateway timeout — large batches × slow historian can exhaust the request pool (`OtOpcUaNodeManager.cs:1902-2203`). *(03, S3)* -- **Any structural deploy change (one added tag) full-rebuilds the address space and kills every client subscription server-wide** — the surgical path covers only attribute edits/renames. Surgical pure-adds are the highest-leverage performance fix in the repo. *(03, P1)* -- **Evaluator-cache ALC accretion**: the live path caches script evaluators in a raw `ConcurrentDictionary` never cleared on republish, leaking collectible AssemblyLoadContexts across script edits; the purpose-built `CompiledScriptCache` is unused. *(02, U3)* - -**Security / authorization group:** - -- **AdminUI's largest mutating surface is effectively ungated** — GlobalUns, EquipmentPage, ClusterEdit, NodeEdit, all 8 driver pages, AclEdit, and Reservations carry only bare `[Authorize]`: any authenticated user, including read-only, can mutate UNS/driver/ACL config. Authorization is a three-idiom mix (bare / role-string / policy). *(04, #1)* - -**Verification-system group:** - -- **CI gates only 5 of 47 test projects** (`.github/workflows/v2-ci.yml:47-52`); Client (388 tests), Tooling, all driver and most Core suites never run in CI. The integration job runs fixture-less, so probes `Assert.Skip` and the badge is green — "skipped" is indistinguishable from "passed". *(07, S-1/S-2)* -- **The sole custom analyzer (OTOPCUA0001) is referenced by zero consuming projects** — the CapabilityInvoker-wrapping rule it enforces is inert in every build. One `ProjectReference` in `Directory.Build.props` fixes it. *(07, C-1)* +- **Failure-visibility group** — applier swallows sink failures / no failure field in `AddressSpaceApplyOutcome` (01/S-1); Galaxy write success optimistic (06/S-1); primary gate default-allow on unknown role (03/S4). STATUS.md queues these as "next"; still the top carried batch. +- **04/C-1 (High)** — AdminUI's largest mutating surface still bare `[Authorize]` (GlobalUns, EquipmentPage, cluster/driver editors, Reservations); three-idiom authz mix. Stakes **rose** this round: the ungated surface now includes live resilience config. +- **Availability** — LDAP block-bridging (03/S2), HistoryRead block-bridging (03/S3), full-rebuild-per-tag-add killing all subscriptions (03/P1 — now unblocked by the U2 forwarding guard, work not started). +- **Driver fleet** — Modbus timeout/TxId desync (05/STAB-3), AbCip shared-handle race / FOCAS dictionary race, `ResolveHost` mis-keying, dead `onError` sink, `ReadEnum` ×6, silent enum defaulting on TagConfig paths. +- **Repo hygiene** — 7 AVEVA DLLs in `lib/`, Wonderware husk dirs, tracked planning files, 5 undocumented CLI commands — all untouched (07). --- -## Cross-cutting themes +## Cross-cutting themes — updated -These emerged independently from multiple reviewers and are the real architectural signal — individual bugs are symptoms of these. +### 1. "Built-but-never-wired" — the guards work, and the pattern still recurred +The two systemic guards shipped and are live (analyzer tree-wide with negative control; reflection-exhaustive sink-forwarding guard; whole-solution CI + fail-on-skip). The analyzer immediately earned its keep by surfacing the RESILIENCE-DISPATCH-GAP, which was then genuinely closed (`bacea1a4` — pragmas removed, not suppressed). **But the pattern reproduced inside the remediation itself**: the bulkhead knob parses-but-never-builds (01/U-6), `WriteIdempotentAttribute` has zero readers (01/S-8), and `DriverResilienceStatusTracker` has no reader (03/U10). The guards catch *interface-forwarding* and *unwrapped-dispatch* inertness; they don't catch *option-parsed-but-unapplied* inertness. A cheap next guard: a test asserting every parsed `DriverResilienceOptions` property influences the built pipeline. -### 1. "Built-but-never-wired" is the house failure mode -At least six independent instances: the dormant `GatewayTagProvisioner` (PR #423, since fixed), the F10b `DeferredAddressSpaceSink` inertness bug (fixed), the analyzer wired to zero projects, `GatewayHistorianDataSource.RefreshConnectionStateAsync` with no production caller (health flags permanently false), the SBR HOCON block with no activating registration, and the entire ~2.4k-line `Core.VirtualTags` engine stack that production bypasses in favor of `VirtualTagHostActor` + `RoslynVirtualTagEvaluator` — which is precisely why the timeout and cache defenses (built into the dormant engine's path) are missing from the live one. **Recommendation:** every hook/capability/config block needs a *production-caller* assertion, not just unit tests of the component — e.g. a reflection-exhaustive forwarding test for `DeferredAddressSpaceSink` (only ~5 of 10 members are asserted today), a startup log line proving SBR is active, and wiring the analyzer repo-wide since it was built to catch exactly this class. +### 2. Fixes that can't be seen by their own verification +The round's sharpest lesson: **05/STAB-14 sits exactly in the blind spot of the live test that validated the fix** (bounce = refused; the bug needs timeout). Same genre: the TwinCAT replay is fake-client-only verified (no fixture exists), and 02/S13 turned a loud failure (hang) into a silent one (stale-Good). When a fix targets a fault path, the verification must enumerate the fault *variants* (refused vs timeout vs desync vs cancel), not just one reproduction. -### 2. Fixes don't flow back to shared templates -Every High in the driver fleet is a fix present in one sibling and absent in another: S7 forked the poll loop (gaining backoff the shared `PollGroupEngine` lacks, keeping a CTS race the engine fixed); AbLegacy guards the handle race AbCip has; OpcUaClient's reconnect machine is exemplary while S7 has none; the `ReadEnum` helper is copy-pasted 6×; no driver passes `onError` to the poll engine so its error sink is dead fleet-wide. The same pattern exists in Core as **duplication-by-convention**: TagConfig intent parsing is byte-parity-replicated in four projects, synced only by "MUST parse identically" comments. **Recommendation:** consolidate into `TagConfigIntent.Parse` in Commons (the `EquipmentScriptPaths` precedent) and treat `PollGroupEngine` + a shared reconnect/backoff primitive as the single home for fleet fixes. +### 3. Optimistic success — unchanged, and now with new instances +The carried trio (applier, Galaxy writes, primary gate) remains open, and the round added stale-Good VT values (02/S13), log-only TwinCAT replay failure (05/STAB-16), and blind-authored resilience config (04/U-5). This is now the largest coherent batch of open work. -### 3. Optimistic success — failure signals don't reach operators -The "Safe*" swallow-and-log wrapper pattern (never fail the caller) recurs across the applier, Galaxy writes, and the primary gate's default-allow. Individually defensible; together they mean a failed deploy reports `rebuilt=true`, a lost Galaxy write reports Good, and a booting secondary accepts writes. **Recommendation:** audit each swallow site for an operator-visible signal (health flag, meter, alert row) — the pattern is fine only when the failure surfaces *somewhere*. +### 4. The verification system is materially better +Biggest mover: all 47 test projects gate CI, skips can't masquerade as green (strict on the unit leg), the analyzer is load-bearing, and the failover + forwarding guards exist with negative controls. Remaining: E2E workflow still gates zero tests (07/S-3), integration matrix runs redundant fixtureless legs (07/P-5), driver suites still carry ~100 fixed sleeps now exposed in CI, no bUnit (04/U-1), and behavioral wiring tests cover only 2 of 6 dispatch sites (03/U6). -### 4. The verification system trusts live-verification that CI can't reproduce -Repo-wide: no bUnit (razor `@code` logic is only live-verified), CI runs ~10% of suites, integration tier skip-gates to green, DPS delivery/actor supervision/hard-kill failover have zero tests, `OpcUaServer.IntegrationTests` has exactly one test, and driver test debt concentrates on reconnect/concurrency — exactly where the four Criticals live. The project's own history (F10b, redundancy-state delivery, enum serialization) shows live-`/run` catching what 250+ green unit tests missed. **Recommendation:** expand `v2-ci.yml` to all unit suites (cheap, immediate), make skips visible (report skipped counts as a failure condition for the integration job), and add a small fault-injection tier (hard-kill a node, drop a PLC connection) even if only run nightly against the docker host. +### 5. Documentation drift — mostly closed, one new instance +CLAUDE.md's Known Limitation 2 and the ScriptAnalysis policy drift are fixed and verified. New drift: `archreview/plans/STATUS.md` merged to master still claiming "all LOCAL — nothing pushed" (corrected alongside this report), and the C6 "Phase 7 plan" residue survived fixdocs. -### 5. Documentation drift on load-bearing claims -CLAUDE.md's KNOWN LIMITATION 2 (empty historized-ref set) is **stale — closed in code** (applier `FeedHistorizedRefs` → recorder `UpdateHistorizedRefs`), confirmed independently by two reviewers; ScriptAnalysis endpoints are documented as FleetAdmin-gated but use `Roles="Administrator,Designer"`; `docs/Client.CLI.md` documents 8 of 14 commands. **Recommendation:** update CLAUDE.md now (and add the missing restart-convergence test + live value-capture verification that the limitation's closure deserves). - -### 6. Equipment-tag (TagConfig-JSON) paths are second-class citizens -Across drivers, the equipment-tag parse/resolve path lags the authored-tag path: silent enum defaulting on typos, forced `Writable:true` (FOCAS is read-only yet defaults writable), `ResolveHost` mis-keying refs to the first device (breaking per-host breaker isolation in 4 drivers), and capability gates bypassed. Since Galaxy-standard-driver made TagConfig the primary authoring surface, this path *is* the product. **Recommendation:** a dedicated hardening pass on the `EquipmentTagRefResolver` seam across all drivers, plus the parse-strictness unification from theme 2. - -### 7. Repo hygiene debt -Retired `Historian.Wonderware*` directories still on disk (absent from slnx); 7 committed proprietary AVEVA DLLs in `lib/` referenced by nothing (license/redistribution risk); `pending.md`/`current.md` tracked despite pending.md's own "never stage" rule; five stale planning files at root. (`sql_login.txt` was verified NOT committed — gitignored, local-only.) +### 6. The resilience seam is the new second-class citizen +Six of this round's nine notable new findings live on the #10/#13 resilience surface. The seam's architecture is clean (correct Polly-free layering, null objects, actor-context preservation — all verified), but its edges are unfinished: no range validation (S-6), dead bulkhead (U-6), hardcoded idempotency (S-8), lossy form (C-7), no status reader (U-5), stale-pipeline respawn race (S-7/S13-03). One focused hardening pass closes all of them. --- -## Prioritized action list +## Prioritized action list (updated) | # | Action | Source | Effort | Impact | |---|---|---|---|---| -| 1 | Activate the split-brain resolver (`ClusterOptions.SplitBrainResolver`) + a hard-kill failover test | 03 S1 | Small | Critical — failover currently doesn't work | -| 2 | Route production VT evaluation through `TimedScriptEvaluator` + `CompiledScriptCache` | 02 U2/U3 | Small | Critical — user script can hang an actor; ALC leak | -| 3 | Add S7 reconnect + TwinCAT ADS re-registration after reconnect (use OpcUaClient's machine as the template) | 05 | Medium | Critical — PLC power-cycle currently defeats both | -| 4 | Classify Modbus timeout/TxId-mismatch as socket-fatal; lock AbCip handles (copy AbLegacy's guard) | 05 | Small | High — silent data corruption/desync | -| 5 | Gate AdminUI mutating pages with real policies (one authz idiom, constants not literals) | 04 | Medium | High — any authenticated user can mutate config | -| 6 | Wire OTOPCUA0001 analyzer repo-wide; expand CI to all unit suites; fail-on-skip for integration | 07 | Small | High — restores meaning to green CI | -| 7 | Add a failure field to `AddressSpaceApplyOutcome`; surface Galaxy write-loss and gate default-deny on unknown role | 01/06/03 | Medium | High — failure visibility | -| 8 | Surgical pure-adds in the address-space applier (stop full rebuilds killing all subscriptions per tag add) | 03 P1 | Large | High — biggest operational perf win | -| 9 | Async LDAP + configurable timeout; channel-ize HistoryRead bridging | 03 S2/S3 | Medium | Medium-High — outage resilience | -| 10 | Consolidate TagConfig parsing (`TagConfigIntent.Parse` in Commons) + driver `ReadEnum`/parse-strictness unification + `ResolveHost` fix | 01 C-1 / 05 | Medium | Medium — kills the byte-parity duplication class | -| 11 | Update CLAUDE.md (Known Limitation 2 closed; ScriptAnalysis policy) + Client.CLI docs; delete Wonderware dirs, `lib/` AVEVA DLLs, untrack planning files | 03/04/06/07 | Small | Medium — doc trust + license risk | -| 12 | Retire or converge the dormant `Core.VirtualTags` engine; delete `MemoryRecycle`/`IDriverSupervisor` dead machinery | 02 U1 / 01 U-2 | Medium | Medium — removes the built-vs-runs divergence | +| 1 | S7: filter connect-timeout OCE in the ensure-wrapper + poll-loop catches; broaden fatal classification (framing/cancel); mark handle dead in probe catch. Add fake-factory connect-timeout test | 05 STAB-14/15 | Small | **Critical-adjacent — regression silently kills S7 subscriptions** | +| 2 | ResilienceConfig hardening pass: range-validate in parser (clamp + diagnostic, never brick) and AdminUI form; wire or delete the bulkhead knob; read `WriteIdempotentAttribute` (or default writes non-idempotent); fix lossy form round-trip | 01 S-6/U-6/S-8, 04 C-7 | Small-Medium | High — operator-authorable driver-brick; command writes retried | +| 3 | Publish Bad quality on VT script failure/timeout (match the dormant engine's BadInternalError); optionally gate the apply-boundary cache clear on changed scripts | 02 S13/P7/S12 | Small | High — silent stale-Good data | +| 4 | Failure-visibility trio: `AddressSpaceApplyOutcome` failure field; Galaxy fail-closed writes; primary-gate default-deny | 01 S-1, 06 S-1, 03 S4 | Medium | High — carried from round 1 | +| 5 | Gate AdminUI mutating pages with real policies (stakes rose: resilience config is on the ungated surface) | 04 C-1 | Medium | High | +| 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 | 06 S-11/U-7 | Small | Medium-High | +| 7 | Surgical pure-adds in the applier (prerequisite guard in place since round 1) | 03 P1 | Large | High — biggest operational perf win | +| 8 | Async LDAP + configurable timeout; channel-ize HistoryRead bridging | 03 S2/S3 | Medium | Medium-High | +| 9 | Driver fleet carried batch: Modbus fatal-classification, AbCip/FOCAS locking, `ResolveHost`, `onError`, TwinCAT replay retry + Bad-quality | 05 | Medium | Medium-High | +| 10 | Resilience observability: a `/hosts` reader for `DriverResilienceStatusTracker` (Stream E.2/E.3) | 03 U10, 04 U-5 | Medium | Medium — operators currently blind | +| 11 | TagConfig parse consolidation (`TagConfigIntent.Parse` in Commons) + `ReadEnum` unification | 01 C-1/P-1, 05 | Medium | Medium | +| 12 | Hygiene: delete `lib/` AVEVA DLLs + Wonderware husks, untrack planning files, document 5 CLI commands, E2E workflow or delete it, dedupe CI integration matrix | 07 | Small | Medium — license risk + doc trust | ## Overall assessment -OtOpcUa is a well-architected system with unusually disciplined conventions, clean layering, strong seams, and near-zero TODO debt — the design work is mature. Its exposure is concentrated in three places: **fault paths** (failover, reconnect, timeout — where all four Criticals live and where test coverage is thinnest), **failure visibility** (a systematic bias toward optimistic success), and **the verification gap** (CI proves ~10% of what the test suite could; the "built-but-never-wired" pattern has now recurred six times because nothing asserts production wiring). The prioritized list above front-loads small, high-leverage fixes: items 1–6 are roughly a week of work and eliminate every Critical plus the two systemic guards (analyzer + CI) that prevent the pattern from recurring. +The remediation round was real: all four Criticals are verifiably closed in code (one by premise-correction), both systemic guards are live and already caught one genuine gap, and the verification system went from "proves ~10%" to whole-solution with skip-detection — the single biggest maturity move available, and it happened. The honest counterweight is that this round's scrutiny of the fixes found what fixes always carry: **one High regression (S7 timeout-vs-refused), one High operator-authorable brick (ResilienceConfig validation), and a cluster of half-wired edges on the new resilience seam** — the house "built-but-never-wired" pattern reproducing inside the code meant to eliminate it. Items 1–3 above are small and close everything new-and-sharp from this round; items 4–5 are the carried Highs that have now waited two rounds and should not wait a third. diff --git a/archreview/01-core-composition.md b/archreview/01-core-composition.md index 274c9936..9c36cb57 100644 --- a/archreview/01-core-composition.md +++ b/archreview/01-core-composition.md @@ -1,22 +1,69 @@ # Architecture Review — Core Composition Pipeline and Core Libraries -**Date:** 2026-07-08 -**Commit:** `9cad9ed0` (master) +**Date:** 2026-07-12 +**Commit:** `f6eaa267` (master, clean tree) +**Note:** This updates the 2026-07-08 review at `9cad9ed0`. Since that baseline the arch-review +remediation branches merged to master; the pieces landing in this domain are: the +`IDriverCapabilityInvoker`/`IDriverCapabilityInvokerFactory` seam in Core.Abstractions + +`DriverCapabilityInvokerFactory` in Core (`bacea1a4`, task #10), per-instance ResilienceConfig +read-path plumbing through the deploy artifact (`75403caa`, task #13), the reflection-exhaustive +deferred-sink forwarding guard in Commons.Tests (`a65c2ced`, task #6), the typed SBR option in +Cluster (`a81dea10` + premise correction `eaf78aad`), the OTOPCUA0001 analyzer wiring (`f0082af5`), +`IScriptCacheOwner` in Commons (`7fd44f0f`), and a fixdocs XML-doc sweep (`3b5ef439`). + **Reviewer scope:** - `src/Core/ZB.MOM.WW.OtOpcUa.Core` (driver hosting, resilience, stability, authorization trie) -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` (driver capability interfaces, `PollGroupEngine`, historian seams) +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` (driver capability interfaces, `PollGroupEngine`, historian seams, the new invoker seam) - `src/Core/ZB.MOM.WW.OtOpcUa.Commons` (cluster message contracts, deferred sinks, NodeId scheme, telemetry) - `src/Core/ZB.MOM.WW.OtOpcUa.Configuration` (EF config persistence, generation-sealed local cache, draft validation) -- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster` (Akka bootstrap, role info, ServiceLevel) +- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster` (Akka bootstrap, role info, ServiceLevel, SBR options) - Plus the address-space composition pipeline itself — `AddressSpaceComposer` / `AddressSpacePlanner` / `AddressSpaceApplier` **Scope note (location drift).** The review brief places the composition pipeline in `ZB.MOM.WW.OtOpcUa.Core`, but since the Phase7→AddressSpace rename it lives in `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/` (`AddressSpaceComposer.cs`, `AddressSpacePlan.cs`, `AddressSpaceApplier.cs`), with its tests under `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/`. -The `Core` project's own `OpcUa/` folder now holds only the retired `EquipmentNodeWalker` and an -unused `GenericDriverNodeManager` (see U-1). The pipeline was reviewed in full regardless. +The `Core` project's own `OpcUa/` folder still holds only the retired `EquipmentNodeWalker` and an +unused `GenericDriverNodeManager` (see U-1). The pipeline was reviewed in full regardless. The +composition pipeline itself received **zero code changes** in the 9cad9ed0→f6eaa267 window; the +domain's delta is concentrated in `Core/Resilience`, `Core.Abstractions`, `Cluster`, and the Runtime +dispatch layer that consumes the new seam. + +--- + +## Prior-finding status (9cad9ed0 → f6eaa267) + +Every prior finding was re-verified against the current code, not the STATUS.md claims. + +| ID | One-line description | Verified status | +|---|---|---| +| S-1 | Applier swallows every sink failure; deploy reported applied even when broken | **STILL OPEN** — `Safe*` helpers unchanged (`AddressSpaceApplier.cs:373–383, 612–622`); `AddressSpaceApplyOutcome` (`:691`) still has no failure fields; `rebuilt = true` still set after `SafeRebuild()` (`:148–153`). STATUS.md itself lists 01/S-1 under "Suggested next". | +| S-2 | `PollGroupEngine.Unsubscribe` blocks up to 5 s per subscription | **STILL OPEN** — `task.Wait(TimeSpan.FromSeconds(5))` at `PollGroupEngine.cs:109`. | +| S-3 | No disposed guard: `Subscribe`/`RegisterAsync` race `DisposeAsync` | **STILL OPEN** — no `_disposed` flag in `PollGroupEngine.cs` or `Core/Hosting/DriverHost.cs`. | +| S-4 | `ScheduledRecycleScheduler` catch-up recycle storm after a stall | **STILL OPEN** — `_nextRecycleUtc += _recycleInterval` (`ScheduledRecycleScheduler.cs:82`), no fast-forward past `utcNow`. | +| S-5 | `GenericDriverNodeManager` re-walk teardown unsynchronized | **STILL OPEN (moot)** — code unchanged; the #10 triage added an OTOPCUA0001 pragma (`GenericDriverNodeManager.cs:69`) explicitly memorializing "zero production references", strengthening the U-1 delete case. | +| P-1 | `Compose` parses each tag's TagConfig JSON four times | **STILL OPEN** — four `JsonDocument.Parse` sites unchanged (`AddressSpaceComposer.cs:541, 606, 638, 670` from `:418–433`). | +| P-2 | Per-operation allocations on the authorization hot path | **STILL OPEN** — `ldapGroups.ToHashSet(...)` per call at `PermissionTrie.cs:42`. | +| P-3 | `PollGroupEngine`: task-per-subscription, no coalescing | **STILL OPEN** — engine unchanged. | +| P-4 | Per-write allocation in the non-idempotent write path | **STILL OPEN** — options record + `Dictionary` per call at `CapabilityInvoker.cs:117–131` (and now production-unreachable — see S-8). | +| C-1 | Byte-parity TagConfig parsing replicated in four projects | **STILL OPEN** — all four copies + "MUST parse identically" comments intact; #13 added a fifth small parity-coupled read (`DeploymentArtifact.TryReadSpec` ResilienceConfig) though that one is a trivial string read. | +| C-2 | Core depends on Configuration; EF entities are the domain model | **STILL OPEN (accepted-risk)** — `Core.csproj:16` still references Configuration. | +| C-3 | `DraftValidator` re-derives NodeId scheme + hard-codes `"GalaxyMxGateway"` | **STILL OPEN** — `DraftValidator.cs:48, 65, 75–97` unchanged. | +| C-4 | Composer violates purity contract with `Trace.TraceWarning` | **STILL OPEN** — `AddressSpaceComposer.cs:493`. | +| C-5 | Inconsistent duplicate-key defensiveness in `Compose` | **STILL OPEN** — last-wins `deviceHostById` (`:357–363`) vs throw-on-dupe `ToDictionary` (`:401–402, 453`). | +| C-6 | Vestigial `Akka` package reference in Commons | **STILL OPEN** — `Commons.csproj:9`. | +| C-7 | Stale scaffold-era XML docs on load-bearing classes | **PARTIALLY FIXED** — fixdocs `3b5ef439` converted `CapabilityInvoker`'s duplicated docs to ``, but both flagged narratives survive verbatim: the applier's F14-scaffold doc ("For now we record the work", "the SDK adapter that lands in F10b will decide…" — `AddressSpaceApplier.cs:7–26`) and `DriverHost`'s "Phase 2 via named-pipe RPC" promise (`Core/Hosting/DriverHost.cs:5–10`). | +| U-1 | Dead/dormant code retained: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` | **STILL OPEN** — all three present (`Core/OpcUa/` lists both classes + `IdentificationFolderBuilder`; `EquipmentScriptPaths.cs:164`). Evidence grew: the analyzer triage now documents GDNM as test-only scaffolding in a pragma. | +| U-2 | Tier C recycle machinery has no `IDriverSupervisor` implementation | **STILL OPEN** — repo-wide search still finds zero concrete implementations; the new `DriverResilienceOptionsParser` even validates `RecycleIntervalSeconds` as "Tier C only" for a tier that cannot execute (`DriverResilienceOptionsParser.cs:101–110`). | +| U-3 | Continuous-historization initial interest set empty at spawn | **PARTIALLY FIXED / RE-ANALYSED** — the recorder is still spawned with `Array.Empty()` (`Runtime/ServiceCollectionExtensions.cs:277`), but re-analysis shows restart convergence is mechanically present: `DriverHostActor.RestoreApplied` (`DriverHostActor.cs:1293–1320`) fires `RebuildAddressSpace` on bootstrap, `OpcUaPublishActor.HandleRebuild` diffs against an **empty** `_lastApplied` baseline → a full-add plan → `Apply` → `FeedHistorizedRefs` posts the complete set. The prior claim "historizes nothing until the next deploy" was too strong. What remains is exactly CLAUDE.md Known Limitation 2 (reworded by the remediation docs branch): no restart-convergence test and no live end-to-end verification. Kept in the body, downgraded. | +| U-4 | Additive-only ACL model (no Deny) | **STILL OPEN (documented v2.0 gap)** — `PermissionTrie.cs:13–17` unchanged. | +| U-5 | Test coverage strong but unevenly distributed | **PARTIALLY IMPROVED** — new suites landed: `SplitBrainResolverActivationTests` (Cluster.Tests, now 4 files), `DriverCapabilityInvokerFactoryTests` (148 lines), pipeline-builder logging tests, and the Commons `DeferredSinkForwardingReflectionTests` guard. But `ClusterRoleInfo` — the only concurrency-bearing Cluster class — still has zero tests, and the S-1/S-3 surfaces remain untestable because they don't exist. Kept in the body, narrowed. | + +Net: **0 of the 5 stability findings, 0 of the 4 performance findings, and 6 of the 7 convention +findings are unchanged**; the remediation wave targeted other domains' Criticals and *landed new +code in this domain* rather than fixing this domain's backlog. That new code is reviewed below +(S-6/S-7/S-8, U-6, C-8). --- @@ -24,7 +71,7 @@ unused `GenericDriverNodeManager` (see U-1). The pipeline was reviewed in full r ### Composition pipeline (deploy → address space) -The pipeline is a clean three-stage compose/diff/apply design: +The pipeline is a clean three-stage compose/diff/apply design (unchanged this window): 1. **`AddressSpaceComposer.Compose`** (`OpcUaServer/AddressSpaceComposer.cs:290`) — a pure static projection from EF config entities (`UnsArea`, `UnsLine`, `Equipment`, `DriverInstance`, `Tag`, @@ -60,35 +107,63 @@ The pipeline is a clean three-stage compose/diff/apply design: The sink boundary (`Commons.OpcUa.IOpcUaAddressSpaceSink`) keeps the whole pipeline SDK-free: production binds `SdkAddressSpaceSink` via the late-swap `DeferredAddressSpaceSink` (actors resolve the wrapper at DI time, the OPC UA hosted service swaps the real sink in after `StandardServer` -starts); dev/tests bind the `NullOpcUaAddressSpaceSink` no-op. +starts); dev/tests bind the `NullOpcUaAddressSpaceSink` no-op. The forwarding contract of that +wrapper is now guarded by a reflection-exhaustive test (`Commons.Tests/OpcUa/ +DeferredSinkForwardingReflectionTests.cs`) — see C-8 for its one blind spot. + +### The new resilience dispatch seam (landed this window) + +The Phase 6.1 `CapabilityInvoker`, which the 2026-07-08 review cycle found built-but-never-wired +(RESILIENCE-DISPATCH-GAP), is now live in production dispatch via a Polly-free seam that mirrors +`IDriverFactory`: + +- **`Core.Abstractions/IDriverCapabilityInvoker.cs`** — the invoker abstraction (3 methods matching + `CapabilityInvoker`'s shapes exactly) plus `NullDriverCapabilityInvoker`, a genuine pass-through. +- **`Core.Abstractions/IDriverCapabilityInvokerFactory.cs`** — per-instance factory abstraction plus + the `Null` factory. +- **`Core/Resilience/DriverCapabilityInvokerFactory.cs`** — the concrete factory: resolves the + driver-type tier, layers per-instance `ResilienceConfig` JSON on the tier defaults via + `DriverResilienceOptionsParser.ParseOrDefaults` (logging the parse diagnostic), calls + `builder.Invalidate(instanceId)` so a respawn with changed options rebuilds cached pipelines, and + snapshots the options once per `Create` so the per-call accessor is allocation-free. +- The Host's `DriverFactoryBootstrap` registers the factory (`Host/Drivers/DriverFactoryBootstrap.cs:63–70`), + Runtime resolves it like `IDriverFactory` (`Runtime/ServiceCollectionExtensions.cs:365–366`), + `DriverHostActor.SpawnChild` creates one invoker per child (`DriverHostActor.cs:1630`), and + `DriverInstanceActor` wraps its six dispatch sites. `DriverSpawnPlanner` forces a respawn on any + ResilienceConfig change (`DriverSpawnPlan.cs:40–46`); `DeploymentArtifact.TryReadSpec` reads the + column null-safely from the artifact (`DeploymentArtifact.cs:168–171`). The OTOPCUA0001 analyzer + is wired tree-wide and knows the interface as a wrapper home, so an unwrapped capability call is a + build error. + +The layering is genuinely clean — Runtime stays Polly-free, null objects keep every test harness +byte-identical, and the invalidate/respawn pairing closes the options-blind-cache trap. The new +findings below (S-6, S-7, S-8, U-6) are all second-order issues *inside* this new machinery, not +problems with its architecture. ### Supporting Core libraries -- **`Core/Hosting`** — `DriverFactoryRegistry` (driver-type → factory), `DriverFactoryRegistryAdapter` - (v1 registry → v2 `IDriverFactory`), `DriverHost` (id → `IDriver` lifecycle registry). +- **`Core/Hosting`** — `DriverFactoryRegistry` (driver-type → factory + `GetTier`), + `DriverFactoryRegistryAdapter`, `DriverHost` (id → `IDriver` lifecycle registry). - **`Core/Resilience`** — Polly pipelines cached per `(DriverInstanceId, HostName, Capability)` - (`DriverResiliencePipelineBuilder`), executed via `CapabilityInvoker` (with the - non-idempotent-write no-retry override), fanned out per host by `AlarmSurfaceInvoker`, observed - by `DriverResilienceStatusTracker` for Admin `/hosts`. -- **`Core/Stability`** — `MemoryTracking` (median-baseline soft/hard breach), `MemoryRecycle` and - `ScheduledRecycleScheduler` (Tier C recycle via `IDriverSupervisor`), `WedgeDetector` - (demand-aware stall detection). + (`DriverResiliencePipelineBuilder`, now with retry/breaker event logging as the interim + observability surface), executed via `CapabilityInvoker` (with the non-idempotent-write no-retry + override), built per instance by `DriverCapabilityInvokerFactory`, fanned out per host by + `AlarmSurfaceInvoker`, observed by `DriverResilienceStatusTracker` for Admin `/hosts`. +- **`Core/Stability`** — `MemoryTracking`, `MemoryRecycle` and `ScheduledRecycleScheduler` + (Tier C recycle via `IDriverSupervisor`), `WedgeDetector`. - **`Core/Authorization`** — generation-sealed `PermissionTrie` per `(ClusterId, GenerationId)` cached in `PermissionTrieCache` (CAS-pruned), walked by `TriePermissionEvaluator` against - per-session `UserAuthorizationState` (freshness window + fail-closed staleness ceiling). -- **`Commons`** — immutable record message contracts for the Akka DPS topics (deploy dispatch/ack, - alerts `AlarmTransitionEvent`, redundancy state, admin ops, script log), strongly-typed ids - (`DeploymentId`, `RevisionHash`, `CorrelationId`, `NodeId`), the deferred sink/publisher pair, - `EquipmentScriptPaths` (shared script-path/dependency extraction), and `OtOpcUaTelemetry` - (central Meter/ActivitySource). -- **`Configuration`** — `OtOpcUaConfigDbContext` (26 entities, logical-id unique indexes, JSON - check constraints, RowVersion concurrency), the generation-sealed LiteDB fallback cache - (`GenerationSealedCache` + atomic `CURRENT` pointer, fail-closed on corruption), - `ResilientConfigReader` (timeout→retry→cache-fallback with secret-scrubbed logging), - `DraftValidator` (all-errors-in-one-pass pre-publish rules). -- **`Cluster`** — `AddOtOpcUaCluster` / `WithOtOpcUaClusterBootstrap` (Akka.Hosting bootstrap with - embedded HOCON + Serilog logger wiring), `ClusterRoleInfo` (lock-guarded role topology snapshot - fed by a subscriber actor), `ServiceLevelCalculator` (pure 0–255 tiering), `RoleParser`. + per-session `UserAuthorizationState`. +- **`Commons`** — immutable record message contracts for the Akka DPS topics, strongly-typed ids, + the deferred sink/publisher pair, `EquipmentScriptPaths`, `OtOpcUaTelemetry`, and now + `Engines/IScriptCacheOwner` (the apply-boundary compile-cache drop capability for + `IVirtualTagEvaluator` implementations). +- **`Configuration`** — `OtOpcUaConfigDbContext` (26 entities), the generation-sealed LiteDB + fallback cache, `ResilientConfigReader`, `DraftValidator`. +- **`Cluster`** — `AddOtOpcUaCluster` / `WithOtOpcUaClusterBootstrap` (now with the extracted, + testable `BuildClusterOptions` setting `KeepOldestOption { DownIfAlone = true }` explicitly — the + #11 premise correction is accurately reflected in the akka.conf comment, the XML doc, and + `SplitBrainResolverActivationTests`), `ClusterRoleInfo`, `ServiceLevelCalculator`, `RoleParser`. Dependency direction: `Core.Abstractions` (leaf) ← `Configuration` ← `Core`; `Commons` (leaf, Akka+Audit packages) ← `Cluster`. The composer/applier in `OpcUaServer` reference both @@ -100,27 +175,28 @@ Akka+Audit packages) ← `Cluster`. The composer/applier in `OpcUaServer` refere ### 1. Stability -#### S-1 — Applier swallows every sink failure; a deploy is reported applied even when the address space is broken — **High** +#### S-1 — Applier swallows every sink failure; a deploy is reported applied even when the address space is broken — **High** *(unchanged since 2026-07-08)* `AddressSpaceApplier` wraps every sink call in catch-all "Safe" helpers that log and continue: `SafeEnsureFolder`/`SafeEnsureVariable` (`AddressSpaceApplier.cs:612–622`), `SafeRebuild` (`AddressSpaceApplier.cs:373–383`), `SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition` (`AddressSpaceApplier.cs:677–687`). `AddressSpaceApplyOutcome` (`AddressSpaceApplier.cs:691`) carries only Added/Removed/Changed counts and `RebuildCalled` — **no failure count**. If -`RebuildAddressSpace()` throws, `rebuilt` is still reported `true` (`AddressSpaceApplier.cs:150–153`) +`RebuildAddressSpace()` throws, `rebuilt` is still reported `true` (`AddressSpaceApplier.cs:148–153`) and the outcome flows back to the deploy coordinator as a success; the deployment seals while the running server holds a partially-materialised (or entirely stale) address space. Individual `EnsureVariable` failures during a materialise pass likewise vanish into per-node Warnings. The never-fail-a-deploy posture is defensible for the *detached* hooks (provisioning, historized-ref feed — both correctly isolated), but structural materialisation failures are the deploy's core -contract. +contract. This is the domain's highest-priority open item; STATUS.md already queues it as a +"failure-visibility High". **Recommendation:** add `FailedNodes`/`RebuildFailed` to `AddressSpaceApplyOutcome`, propagate into the `ApplyAck`/`DeploymentFailed` decision and the audit log, and escalate `SafeRebuild` failure to at least a degraded ack. Tests should assert the failure surface (the fixture sink can throw). -#### S-2 — `PollGroupEngine.Unsubscribe` blocks the calling thread up to 5 s per subscription — **Medium** +#### S-2 — `PollGroupEngine.Unsubscribe` blocks the calling thread up to 5 s per subscription — **Medium** *(unchanged)* `StopState` does a synchronous `task.Wait(TimeSpan.FromSeconds(5))` (`Core.Abstractions/PollGroupEngine.cs:99–112`). `Unsubscribe` is the teardown path drivers call @@ -134,45 +210,114 @@ not. background drain), keeping the "no callback after teardown" guarantee via the existing LoopTask/CTS-dispose ordering. -#### S-3 — `PollGroupEngine.Subscribe` has no disposed guard — **Low** +#### S-3 — `PollGroupEngine.Subscribe` has no disposed guard — **Low** *(unchanged)* `Subscribe` racing `DisposeAsync` can insert a fresh `SubscriptionState` after the dispose loop has snapshotted `_subscriptions.Values` (`PollGroupEngine.cs:73–84` vs `213–236`), leaking a live poll loop with no owner. Same pattern in `DriverHost.RegisterAsync` vs `DisposeAsync` -(`Core/Hosting/DriverHost.cs:53–71` vs `92–106`): a registration that wins the race after the -snapshot+clear is initialized but never shut down. Both are shutdown-window races, unlikely in +(`Core/Hosting/DriverHost.cs:53–71` vs `92–106`). Both are shutdown-window races, unlikely in practice but cheap to close with a `_disposed` flag checked inside the lock / before insert. -#### S-4 — `ScheduledRecycleScheduler` catch-up recycle storm after a stall — **Low** +#### S-4 — `ScheduledRecycleScheduler` catch-up recycle storm after a stall — **Low** *(unchanged)* `TickAsync` advances `_nextRecycleUtc` by exactly one interval per fire -(`Core/Stability/ScheduledRecycleScheduler.cs:72–84`). If the host was suspended (VM pause, long +(`Core/Stability/ScheduledRecycleScheduler.cs:74–82`). If the host was suspended (VM pause, long outage) across K intervals, the next K ticks each trigger a full Tier C process recycle back-to-back. **Recommendation:** fast-forward `_nextRecycleUtc` past `utcNow` after a fire. -#### S-5 — `GenericDriverNodeManager` re-walk teardown is not synchronized — **Low** +#### S-5 — `GenericDriverNodeManager` re-walk teardown is not synchronized — **Low** *(unchanged; dormancy now formally documented)* `BuildAddressSpaceAsync` tears down the previous alarm forwarder and clears `_alarmSinks` -non-atomically (`Core/OpcUa/GenericDriverNodeManager.cs:58–71`); a concurrent driver alarm event -during the window between unsubscribe and re-subscribe is dropped, and two concurrent Build calls -would interleave badly. Currently moot because the class has no production caller (see U-1) — fold -this into whatever decision is made there. +non-atomically (`Core/OpcUa/GenericDriverNodeManager.cs:58–73`). Still moot: the #10 analyzer triage +added a pragma at `GenericDriverNodeManager.cs:69` stating outright that this class has "zero +production references" and is exercised only by its own tests — fold this into the U-1 decision. -**Positive observations.** The failure-mode engineering in `Configuration/LocalCache` is exemplary: -`GenerationSealedCache` writes temp-file + atomic `File.Replace` pointer swaps, fails closed on any -corruption rather than silently serving an older generation (`GenerationSealedCache.cs:111–155`); -`LiteDbConfigCache` documents and defuses two real LiteDB concurrency traps (global `BsonMapper` -races, find-then-insert upsert races) (`LiteDbConfigCache.cs:16–36`). `ResilientConfigReader` -correctly distinguishes SQL-command `TaskCanceledException` from genuine caller cancellation -(`ResilientConfigReader.cs:117–138`) and scrubs connection-string secrets from log messages. -`PermissionTrieCache.Prune` is a correct reference-equality CAS loop (`PermissionTrieCache.cs:73–102`). -The applier's two historian hooks are properly detached (continuation observes the task, never -`.Result` on a fault — `AddressSpaceApplier.cs:259–286`). `PollGroupEngine` classifies fatal -exceptions and hardens the `onError` callback (`PollGroupEngine.cs:153–169`). +#### S-6 — NEW: a valid-JSON `ResilienceConfig` with an out-of-range timeout permanently fails every guarded capability call — violates the parser's own never-brick contract — **High** + +`DriverResilienceOptionsParser.ParseOrDefaults` defends against *malformed JSON* (`catch +(JsonException)` at `DriverResilienceOptionsParser.cs:71–76`), unknown capability names, and a +misapplied Tier-C recycle interval — but performs **no range validation** on the numeric override +values it merges (`DriverResilienceOptionsParser.cs:91–95`): `TimeoutSeconds`, `RetryCount`, and +`BreakerFailureThreshold` pass straight through. The pipeline builder then does +`builder.AddTimeout(new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(policy.TimeoutSeconds) })` +(`DriverResiliencePipelineBuilder.cs:111–114`). Polly v8 range-validates `TimeoutStrategyOptions.Timeout` +(and `RetryStrategyOptions.MaxRetryAttempts`) at pipeline build time — so `"timeoutSeconds": 0`, a +negative value, or an absurdly large one throws a `ValidationException` inside the +`_pipelines.GetOrAdd` factory on the driver's **first capability call**, and on **every subsequent +call** (a throwing factory caches nothing). Result: subscribe, discover, write, and alarm-ack all +fail persistently for that driver while the driver itself is healthy — precisely the "misconfigured +ResilienceConfig should never brick a working driver" outcome the parser's remarks +(`DriverResilienceOptionsParser.cs:30–33`) promise to prevent. + +This is operator-reachable: the AdminUI's `ResilienceFormModel` binds `TimeoutSeconds` as a bare +`int?` with **no range validation anywhere in the form model** +(`AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs:32–37`), so typing `0` into the timeout +field, publishing, and deploying produces the failure. And because #13 correctly forces a respawn on +any ResilienceConfig change, the bad config takes effect immediately. + +**Recommendation:** clamp/reject out-of-range values in `ParseOrDefaults` (the diagnostic +out-parameter already exists — `TimeoutSeconds < 1 → tier default + diagnostic`, cap at Polly's max; +`RetryCount < 0 or > 100 → clamp`), add a parser test per field, and mirror the same bounds as +form-side validation in `ResilienceFormModel`. Defense in depth belongs in the parser: the JSON +column is writable by more paths than the AdminUI form. + +#### S-7 — NEW: respawn `Invalidate` race can permanently re-cache a stale-options pipeline — **Low** + +`DriverCapabilityInvokerFactory.Create` calls `_builder.Invalidate(driverInstanceId)` +(`DriverCapabilityInvokerFactory.cs:71`) before returning the new invoker, and `DriverHostActor` +processes `plan.ToStop` before `plan.ToSpawn` in the same handler (`DriverHostActor.cs:1278–1280`) +— but `StopChild` is an asynchronous actor stop. An in-flight capability call still executing inside +the *old* child's handler can reach `GetOrCreate` **after** the new spawn's `Invalidate` has run, +re-populating the cache with a pipeline built from the **old** options snapshot. Because the builder +deliberately ignores options on a cache hit (`DriverResiliencePipelineBuilder.cs:66–79`), the new +invoker then serves that stale pipeline **indefinitely** — the transient race has a permanent +consequence, silently undoing the operator's ResilienceConfig change for the affected +(host, capability) keys. The window is small (one in-flight handler) but the failure is invisible. + +**Recommendation:** make staleness impossible rather than racing it — include an options generation +(or the options reference) in `PipelineKey`, or have `Create` capture a per-invoker generation the +old invoker can no longer write into. Alternatively, defer the new spawn until the old child's +`Terminated` is observed (the actor-name generation counter shows the respawn already tolerates the +old child lingering). + +#### S-8 — NEW: production dispatch hardcodes `isIdempotent: true`; the non-idempotent write safeguard is unreachable — **Medium** + +The only production call to `ExecuteWriteAsync` is `DriverInstanceActor.HandleWriteAsync` passing +`isIdempotent: true` unconditionally (`Runtime/Drivers/DriverInstanceActor.cs:597–601`); a +repo-wide search finds no caller passing `false`. The `!isIdempotent` no-retry branch in +`CapabilityInvoker.ExecuteWriteAsync` (`CapabilityInvoker.cs:117–131`) — the mechanism Phase 6.1 +built specifically so non-idempotent device writes are never retried — is production-dead code. The +inline comment's reasoning ("an OPC UA attribute set is idempotent") holds for plain value writes, +but drivers surface write-through targets where a duplicated write is *not* harmless +(command/pulse coils, increment registers, Galaxy supervisory writes), and the +`WriteIdempotentAttribute` the invoker was designed around is never consulted at the seam. Today +this is behavior-neutral (every tier's Write default is `RetryCount: 0` — +`DriverResilienceOptions.cs:83, 94, 105`), but the entire point of #13 is that an operator can now +switch Write retries on per instance — and the moment one does, **all** of that driver's writes +retry, with the no-retry safety valve unreachable. + +**Recommendation:** either thread the tag-level idempotence signal (from `WriteIdempotentAttribute` +/ driver metadata) through `WriteAttribute` to the dispatch site, or — if all-writes-idempotent is +the deliberate v1 posture — delete the `isIdempotent` parameter from the seam and document the +invariant, so the dead branch (and its per-write allocation, P-4) stops implying a guarantee that +isn't wired. + +**Positive observations.** The failure-mode engineering in `Configuration/LocalCache` remains +exemplary (fail-closed `GenerationSealedCache`, atomic pointer swaps, the documented LiteDB traps); +`ResilientConfigReader` and `PermissionTrieCache.Prune` are unchanged and correct. The applier's two +historian hooks are properly detached (`AddressSpaceApplier.cs:255–300`). The new remediation code is +itself defensively built: `TryReadSpec` reads `ResilienceConfig` null-safely and the spec survives +absent/null values (`DeploymentArtifact.cs:168–181`); the factory logs parse diagnostics instead of +throwing; `Invalidate` is a correct enumerate-and-`TryRemove` pass over the concurrent dictionary; +`DriverSpawnPlanner`'s respawn-on-ResilienceConfig-change with in-place delta for pure DriverConfig +changes (`DriverSpawnPlan.cs:40–46`) is exactly the right split; and the `DriverInstanceActor` +wrapping preserved the hard-won no-`ConfigureAwait(false)`-on-the-actor-await rule with an explicit +comment that the invoker's internal `ConfigureAwait(false)` does not leak to the caller +(`DriverInstanceActor.cs:290–300`) — backed by a real-invoker actor-context test. ### 2. Performance -#### P-1 — `Compose` parses each tag's `TagConfig` JSON four times — **Medium** +#### P-1 — `Compose` parses each tag's TagConfig JSON four times — **Medium** *(unchanged)* `ExtractTagFullName`, `ExtractTagAlarm`, `ExtractTagHistorize` and `ExtractTagArray` each do their own `JsonDocument.Parse` of the same blob per tag (`AddressSpaceComposer.cs:418–433` calling @@ -183,7 +328,7 @@ path, so this is a scalability tax rather than a latency bug today. **Recommendation:** parse once per tag into a small `TagConfigIntent` record (which also collapses the byte-parity duplication — see C-1). -#### P-2 — Per-operation allocations on the authorization hot path — **Medium** +#### P-2 — Per-operation allocations on the authorization hot path — **Medium** *(unchanged)* `PermissionTrie.CollectMatches` allocates a fresh `HashSet` from the session's LDAP groups plus a `List` on every call (`Core/Authorization/PermissionTrie.cs:42–45`), and @@ -192,77 +337,69 @@ CreateMonitoredItems batch turns this into per-node garbage. `UserAuthorizationS immutable per membership refresh — the case-insensitive set can be computed once per session version and reused. -#### P-3 — `PollGroupEngine`: one background task + independent timer per subscription, no cross-subscription batching — **Medium** +#### P-3 — `PollGroupEngine`: one background task + independent timer per subscription, no cross-subscription batching — **Medium** *(unchanged)* Each `Subscribe` spawns a dedicated `Task.Run` loop (`PollGroupEngine.cs:73–84`). Drivers that map each OPC UA monitored-item group (or each poll group) to a subscription get linear task/timer growth, and two subscriptions polling overlapping tag sets at the same interval issue duplicate protocol reads — the engine has no interval-bucketed scheduler or read coalescing. Fine at dozens -of subscriptions; a scaling wall at hundreds per driver instance. Worth a note in the driver-facing -docs at minimum; an interval-bucketed shared loop is the structural fix if tag counts grow. +of subscriptions; a scaling wall at hundreds per driver instance. -#### P-4 — Per-write allocation in the non-idempotent write path — **Low** +#### P-4 — Per-write allocation in the non-idempotent write path — **Low** *(unchanged; now also dead code — see S-8)* `CapabilityInvoker.ExecuteWriteAsync` builds a new options record + `Dictionary` per non-idempotent -write (`Core/Resilience/CapabilityInvoker.cs:135–152`) even though `GetOrCreate` keys only on -`(id, host, capability)` and ignores the options after the first build — the comment even cites the -"1% pipeline budget". Cache the no-retry snapshot per options generation, or key the fast path on a -precomputed pipeline. +write (`Core/Resilience/CapabilityInvoker.cs:117–131`) even though `GetOrCreate` keys only on +`(id, host, capability)` and ignores the options after the first build. Since the only production +caller now passes `isIdempotent: true` (S-8), this path is unreachable — resolve it together with +S-8 (either cache the no-retry snapshot or delete the branch). -**Positive observations.** Pipeline resolution is lock-free `ConcurrentDictionary` reads -(`DriverResiliencePipelineBuilder.cs:58–71`). The planner's diff is O(N) dictionary passes with -deterministic sorted outputs. The surgical-apply path exists precisely to avoid the expensive full -rebuild + subscription teardown for attribute-only edits, and its whitelist-via-`with`-expression -technique (`AddressSpaceApplier.cs:630–663`) is future-field-safe (unknown new fields force the -safe rebuild). Telemetry instruments are no-op until a listener attaches (`OtOpcUaTelemetry.cs`). +**Positive observations.** Pipeline resolution remains lock-free `ConcurrentDictionary` reads; the +new factory snapshots options **once per `Create`** so the per-call accessor is allocation-free — +an explicit hot-path improvement over re-parsing per call. The planner's diff is O(N) dictionary +passes with deterministic sorted outputs; the surgical-apply path and its +whitelist-via-`with`-expression technique (`AddressSpaceApplier.cs:630–663`) are unchanged and +future-field-safe. The new resilience logging only attaches callbacks when a tracker or logger is +present, so unit-test pipelines stay callback-free. ### 3. Conventions -#### C-1 — "Byte-parity by convention": TagConfig parsing replicated in four projects with comment-enforced sync — **High** +#### C-1 — "Byte-parity by convention": TagConfig parsing replicated in four projects with comment-enforced sync — **High** *(unchanged)* `ExtractTagFullName` (and friends) exist as deliberate copies in: - `OpcUaServer/AddressSpaceComposer.cs:536–551` (+ `ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray`/`TryExtractDeviceHost`) - `Runtime/Drivers/DeploymentArtifact.cs` (the artifact-decode mirror, per its own comments) - `Core/OpcUa/EquipmentNodeWalker.cs:193–208` -- `Configuration/Validation/DraftValidator.cs:60–71` (a fourth "small local copy, consistent with - this codebase where the composer keeps its own") +- `Configuration/Validation/DraftValidator.cs:60–71` Each copy carries a "MUST parse identically (byte-parity)" comment. The equality of the deployed address space across the live-compose and artifact-decode seams — and therefore the correctness of the diff/no-op-redeploy behavior — rests on humans keeping four JSON parsers in sync. The codebase already demonstrated the fix once: `EquipmentScriptPaths` (`Commons/Types/EquipmentScriptPaths.cs`) -was created exactly to de-duplicate the `ctx.GetTag` extraction "those two seams used to duplicate". +was created exactly to de-duplicate the `ctx.GetTag` extraction. -**Recommendation:** move the TagConfig/DeviceConfig intent parsing into `Commons` (it has no EF or -SDK dependency; every current copy's host project already references Commons or could) as a single +**Recommendation:** move the TagConfig/DeviceConfig intent parsing into `Commons` as a single `TagConfigIntent.Parse(string)` — also resolving P-1 — and keep one cross-seam parity test instead of N replicated implementations. -#### C-2 — Core depends on Configuration: EF entities and enums are the domain model — **Medium** +#### C-2 — Core depends on Configuration: EF entities and enums are the domain model — **Medium** *(unchanged, accepted-risk)* -`ZB.MOM.WW.OtOpcUa.Core.csproj` references `Configuration`; `PermissionTrie` consumes -`Configuration.Enums.NodePermissions`/`NodeAclScopeKind` (`Core/Authorization/PermissionTrie.cs:1`), -`EquipmentNodeWalker` consumes `Configuration.Entities` directly, and the composer's plan records -are projections of EF entities. There is no separate domain layer — persistence types *are* the -model. This is a deliberate, consistent choice (and the plan records do decouple the applier), but -it means: (a) any EF-driven entity change ripples straight into authorization and composition -logic; (b) `Configuration` (with EF Core, SqlServer, LiteDB, DataProtection packages) is pulled -into anything referencing Core. Flagging as accepted-risk to document rather than refactor — -new code should keep preferring the plan-record boundary the composer established. +`ZB.MOM.WW.OtOpcUa.Core.csproj:16` references `Configuration`; `PermissionTrie` consumes +`Configuration.Enums`, `EquipmentNodeWalker` consumes `Configuration.Entities` directly, and the +composer's plan records are projections of EF entities. A deliberate, consistent choice — flagged +as accepted-risk to document rather than refactor. Notably, the new remediation code got this +boundary **right**: the invoker seam lives in `Core.Abstractions` precisely so Runtime avoids the +Core→Polly closure — new code should keep preferring that pattern. -#### C-3 — `DraftValidator` re-derives the NodeId scheme and hard-codes a driver-type string — **Medium** +#### C-3 — `DraftValidator` re-derives the NodeId scheme and hard-codes a driver-type string — **Medium** *(unchanged)* `ValidateNoEquipmentSignalNameCollision` re-implements the folder-scoped NodeId key by hand (`Configuration/Validation/DraftValidator.cs:75–97`) because `Configuration` cannot reference -`Commons.OpcUa.EquipmentNodeIds` (dependency direction) — a third copy of the -`{equipment}/{folder}/{name}` scheme whose drift would silently break the collision check. -`ValidateGalaxyTagFullName` hard-codes `dtype != "GalaxyMxGateway"` -(`DraftValidator.cs:41–55`) rather than a shared driver-type constant. Both are symptoms of the -same missing shared-contract home as C-1; a Commons-level scheme/constants type (or moving -`EquipmentNodeIds` down to a project Configuration can see) fixes both. +`Commons.OpcUa.EquipmentNodeIds` — a third copy of the `{equipment}/{folder}/{name}` scheme. +`ValidateGalaxyTagFullName` hard-codes `dtype != "GalaxyMxGateway"` (`DraftValidator.cs:48`). Both +are symptoms of the same missing shared-contract home as C-1. -#### C-4 — Composer violates its own purity contract with `Trace.TraceWarning` — **Low** +#### C-4 — Composer violates its own purity contract with `Trace.TraceWarning` — **Low** *(unchanged)* The class doc says "Same inputs → same outputs, no logging" (`AddressSpaceComposer.cs:280–283`), but the dangling-predicate-script skip emits `Trace.TraceWarning` (`AddressSpaceComposer.cs:493–496`) @@ -270,103 +407,147 @@ but the dangling-predicate-script skip emits `Trace.TraceWarning` (`AddressSpace production sinks. Either return skipped-alarm ids in the composition for the caller to log, or accept an injected logger and fix the doc. -#### C-5 — Inconsistent duplicate-key defensiveness inside `Compose` — **Low** +#### C-5 — Inconsistent duplicate-key defensiveness inside `Compose` — **Low** *(unchanged)* `deviceHostById` is built with a deliberate last-wins `foreach` and a comment explaining why -`ToDictionary` (throw-on-dupe) would diverge from the decode side (`AddressSpaceComposer.cs:352–364`), -yet three lines later `driversById`/`namespacesById`/`scriptsById` use plain `ToDictionary` -(`AddressSpaceComposer.cs:401–402, 453`) — a duplicate logical id (only possible on DB-constraint -bypass) crashes the whole compose. Pick one posture; the defensive one is already argued for. +`ToDictionary` (throw-on-dupe) would diverge from the decode side (`AddressSpaceComposer.cs:357–363`), +yet `driversById`/`namespacesById`/`scriptsById` use plain `ToDictionary` +(`AddressSpaceComposer.cs:401–402, 453`). Pick one posture; the defensive one is already argued for. -#### C-6 — Vestigial `Akka` package reference in Commons — **Low** +#### C-6 — Vestigial `Akka` package reference in Commons — **Low** *(unchanged)* -`Commons.csproj` references the `Akka` package but no Commons source uses an Akka type (only doc -comments mention actors). Harmless today because every consumer is Akka-hosted anyway, but it -contradicts Commons' role as the dependency-light contracts layer. Remove it. +`Commons.csproj:9` references the `Akka` package but no Commons source uses an Akka type (the new +`IScriptCacheOwner` doesn't either — only doc comments mention actors). Remove it. -#### C-7 — Stale scaffold-era XML docs on load-bearing classes — **Low** +#### C-7 — Stale scaffold-era XML docs on load-bearing classes — **Low** *(partially fixed)* -`AddressSpaceApplier`'s class doc still describes the F14 scaffold ("For now we record the work", -"the SDK adapter that lands in F10b will decide…" — `AddressSpaceApplier.cs:7–26`) although both -features shipped. `DriverHost`'s doc promises "per-process isolation for Tier C … implemented in -Phase 2 via named-pipe RPC" (`Core/Hosting/DriverHost.cs:5–10`), an architecture superseded by the -out-of-repo mxaccessgw gateway. In a codebase whose XML documentation is otherwise a genuine asset -(and evidently relied on), stale top-of-class narratives are actively misleading. +The fixdocs sweep (`3b5ef439`) fixed `CapabilityInvoker`'s doc duplication, but both narratives this +finding flagged survive verbatim: `AddressSpaceApplier`'s class doc still describes the F14 scaffold +("For now we record the work", "the SDK adapter that lands in F10b will decide…" — +`AddressSpaceApplier.cs:7–26`) although both features shipped, and `DriverHost`'s doc still promises +"per-process isolation for Tier C … implemented in Phase 2 via named-pipe RPC" +(`Core/Hosting/DriverHost.cs:5–10`), an architecture superseded by the out-of-repo gateways. In a +codebase whose XML documentation is otherwise a genuine asset, stale top-of-class narratives are +actively misleading. -**Positive observations.** Naming and structure are highly consistent (logical-id + RowVersion -entity pattern, `Null*`/`Deferred*` sink pattern, options records with `Validate()`, message -records per DPS topic). Zero `TODO`/`HACK`/`FIXME`/`Obsolete` markers across all five projects. The -DbContext matches a documented schema with an introspection compliance test -(`SchemaComplianceTests`). The `DeferredAddressSpaceSink` explicitly forwards the surgical -capability with a comment memorializing the F10b prod-inertness bug (`DeferredAddressSpaceSink.cs:52–70`) -— institutional memory encoded where the next person will trip. +#### C-8 — NEW: the deferred-sink forwarding guard's "exhaustive" interface detection is namespace-filtered — **Low** + +`DeferredSinkForwardingReflectionTests.DeferredAddressSpaceSink_implements_exactly_the_known_forwarding_interfaces` +— the guard-of-the-guard whose whole job is catching a *new* capability interface added to the +wrapper — filters `GetInterfaces()` to `i.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace` +(`Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs:44`), i.e. only +`ZB.MOM.WW.OtOpcUa.Commons.OpcUa`. Commons already has sibling namespaces the wrapper could +plausibly grow an interface from (`Commons.Engines` — home of the brand-new `IScriptCacheOwner` — +and `Commons.Types`): a future forwarding interface declared outside `Commons.OpcUa` would be +silently excluded from `ForwardingInterfaces` enforcement, re-opening exactly the +F10b/PR#423 inert-capability trap class this guard exists to close. The per-method forwarding test +and the `DummyArg` fabricator are otherwise well built (the `DispatchProxy` recorder auto-covers +future members; bool-returning members return `true` so capability-sniffing short-circuits still +record). + +**Recommendation:** drop the namespace filter and instead exclude a small explicit allowlist of +known non-forwarding interfaces (e.g. `IDisposable`), so any *unknown* interface trips the guard +regardless of where it lives. + +**Positive observations.** Naming and structure remain highly consistent, and the new code follows +the house patterns exactly: the seam mirrors `IDriverFactory`, both null objects are private-ctor +singletons, options records keep `Validate()`-style diagnostics, and every new public member is +documented. Two convention-enforcement mechanisms landed that materially raise the floor: the +OTOPCUA0001 analyzer is wired tree-wide (an unwrapped capability call is now a build error, with +the one remaining pragma carrying a full rationale — `GenericDriverNodeManager.cs:69`), and the +reflection forwarding guard turns the deferred-sink trap from a code-review convention into a +failing test. The #11 premise correction is honest engineering — the SBR comments/docs now state +that the typed option is reinforcing, not the activator, instead of quietly keeping the wrong +war story. ### 4. Underdeveloped Areas -#### U-1 — Dead/dormant code retained in Core: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` — **Medium** +#### U-1 — Dead/dormant code retained in Core: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` — **Medium** *(unchanged; evidence strengthened)* -- `GenericDriverNodeManager` (`Core/OpcUa/GenericDriverNodeManager.cs`) has **no production - references** — only its own test file. The production path is the composer→applier→sink chain. +- `GenericDriverNodeManager` (`Core/OpcUa/GenericDriverNodeManager.cs`) has no production references + — now stated *in the source itself* by the #10 triage pragma ("zero production references", + `GenericDriverNodeManager.cs:69`). - `EquipmentNodeWalker` (`Core/OpcUa/EquipmentNodeWalker.cs:160–165`) self-declares "retained for - unit-test support only; real server deployments never invoke Walk" — ~280 lines plus - `IdentificationFolderBuilder` and a full test suite exercising a code path that cannot run in - production. Its `ExtractFullName` copy is also one of the four C-1 duplicates. + unit-test support only" — ~280 lines plus `IdentificationFolderBuilder` and a full test suite for + a path that cannot run in production. Its `ExtractFullName` copy is one of the four C-1 duplicates. - `EquipmentScriptPaths.TryParseRelayBody` (`Commons/Types/EquipmentScriptPaths.cs:164–172`) - serviced the relay→alias converter that was deleted when Galaxy became a standard driver - (2026-06-12); only tests call it now. + serviced the deleted relay→alias converter; only tests call it. Dormant code with green tests is worse than deleted code: it passes every gate while silently -diverging from the live path. **Recommendation:** delete the walker + GDNM + relay parser (git -preserves them), or, if the walker must survive as a test fixture, move it into the test project. +diverging from the live path — the review cycle's own RESILIENCE-DISPATCH-GAP episode proved the +genre. **Recommendation:** delete all three (git preserves them), or move the walker into the test +project if it must survive as a fixture. -#### U-2 — The entire Tier C recycle machinery has no `IDriverSupervisor` implementation — **Medium** +#### U-2 — The entire Tier C recycle machinery has no `IDriverSupervisor` implementation — **Medium** *(unchanged; now accreting further)* `MemoryRecycle`, `ScheduledRecycleScheduler`, and `DriverResilienceStatusTracker.RecordRecycle` -all drive `IDriverSupervisor.RecycleAsync`, but a repo-wide search finds **zero concrete -implementations** of `IDriverSupervisor` (`Core.Abstractions/IDriverSupervisor.cs` + the two -Stability consumers are the only references). Since the out-of-process driver story moved to the -external mxaccessgw/HistorianGateway sidecars, no in-repo driver is Tier C, so the recycle path is -compiled, tested, and unreachable. Either document it as speculative infrastructure for a future -tier-migration workflow (the `MemoryRecycle` doc hints at one) or prune it alongside U-1. +all drive `IDriverSupervisor.RecycleAsync`, but a repo-wide search still finds **zero concrete +implementations**. The gap is now *growing* supporting surface: `DriverResilienceOptionsParser` +carefully gates `RecycleIntervalSeconds` to "Tier C only" (`DriverResilienceOptionsParser.cs:101–110`) +and the AdminUI authors the field — configuration and validation for an execution path that does +not exist. Either document it as speculative infrastructure for a future tier-migration workflow or +prune it alongside U-1. -#### U-3 — Continuous-historization interest set: delta feed landed, initial set still empty — **Medium (known-limitation debt)** +#### U-3 — Continuous-historization restart convergence: mechanically present, unverified — **Low** *(downgraded from Medium; prior framing partially superseded)* -The applier now pushes add/remove historized-ref deltas per deploy -(`AddressSpaceApplier.cs:310–353` → `IHistorizedTagSubscriptionSink`), which is the convergent -design for *subsequent* deploys — but per the documented KNOWN LIMITATION 2 (CLAUDE.md), the -recorder is still spawned with an empty ref set, so after a process restart the recorder historizes -nothing until the next deploy produces a delta. The seam contract -(`Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs`) supports the fix; the missing -piece is a full-set replay at spawn/rebuild (e.g. have the applier feed the *complete* current set -on `RebuildCalled`, not just the diff). This is the highest-leverage half-finished feature in the -subsystem. +Re-analysis this cycle shows the prior "after a restart the recorder historizes nothing until the +next deploy" framing was too strong: `DriverHostActor.RestoreApplied` fires a `RebuildAddressSpace` +on bootstrap (`DriverHostActor.cs:1293–1320`), and `OpcUaPublishActor.HandleRebuild` diffs the +restored artifact against an empty `_lastApplied` baseline (`OpcUaPublishActor.cs:290–340`) — a +full-add plan whose `Apply` feeds the **complete** historized set through `FeedHistorizedRefs` +(`AddressSpaceApplier.cs:310–353`). The spawn-time empty set +(`Runtime/ServiceCollectionExtensions.cs:269–278`) is therefore covered by the restore path, and the +recorder is deliberately spawned before the applier wiring so the feed can reach it. What remains is +exactly what CLAUDE.md Known Limitation 2 (as reworded by the remediation) says: **no +restart-convergence test** asserting the recorder re-registers the deployed set after a process +restart, and no live end-to-end verification. Given the subsystem's history of +mechanically-present-but-inert paths, that test is cheap insurance and should be written. -#### U-4 — Additive-only ACL model (no Deny) is a documented v2.0 gap — **Low** +#### U-4 — Additive-only ACL model (no Deny) is a documented v2.0 gap — **Low** *(unchanged)* `PermissionTrie` is explicitly pure-union ("no explicit Deny in v2.0", `Core/Authorization/PermissionTrie.cs:13–17`): a broad cluster-root grant cannot be carved back at -a sub-scope. Acceptable for the current flat-role deployments (AdminUI roles are global by design), -but it bounds how fine-grained OT authorization can get without a trie-walk semantics change — -worth keeping visible in the security docs. +a sub-scope. Acceptable for the current flat-role deployments, but it bounds how fine-grained OT +authorization can get — keep visible in the security docs. -#### U-5 — Test coverage is strong but unevenly distributed — **Low** +#### U-5 — Test coverage: `ClusterRoleInfo` remains the riskiest untested seam — **Low** *(narrowed from prior)* -Test method counts: Core.Tests 208, Configuration.Tests 94, Core.Abstractions.Tests 65, -Commons.Tests 45, Cluster.Tests 21. The composition pipeline itself is thoroughly covered (10+ -dedicated files in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpace*`). Gaps: -`ClusterRoleInfo` — the only concurrency-bearing class in the Cluster project (lock + subscriber -actor + event fan-out) — has no test (Cluster.Tests covers only the three pure classes); -`DriverHost` dispose/register races (S-3) and the applier failure surface (S-1) are untested -because the surface doesn't exist. An Akka TestKit harness for `ClusterRoleInfo` leader-change -sequencing would close the riskiest untested seam. +This cycle materially improved the domain's test posture: `SplitBrainResolverActivationTests` +(Cluster.Tests), `DriverCapabilityInvokerFactoryTests` (invalidate-per-instance, malformed-config +fallback, override-reaches-execution), pipeline-builder logging tests, and the Commons forwarding +reflection guard all landed. Still missing: `ClusterRoleInfo` — the only concurrency-bearing class +in the Cluster project (lock + subscriber actor + event fan-out) — has **no test** (Cluster.Tests +still covers only pure classes plus the SBR option), and the S-1 applier failure surface and S-3 +dispose races remain untested because the surfaces don't exist. An Akka TestKit harness for +`ClusterRoleInfo` leader-change sequencing would close the riskiest untested seam. + +#### U-6 — NEW: bulkhead is advertised, authored, parsed — and never applied — **Medium** + +The resilience surface documents "retry / circuit-breaker / bulkhead / tracker telemetry" in the +seam docs (`IDriverCapabilityInvoker.cs:4–6`), the analyzer's diagnostic message +(`UnwrappedCapabilityCallAnalyzer.cs:105`), and the tracker's "Polly bulkhead-depth column" doc; +`DriverResilienceOptions` carries `BulkheadMaxConcurrent`/`BulkheadMaxQueue` defaults +(`DriverResilienceOptions.cs:23–25`); the parser merges operator overrides for both +(`DriverResilienceOptionsParser.cs:116–117`); and the AdminUI form authors them +(`ResilienceFormModel.cs:18–20`). But `DriverResiliencePipelineBuilder.Build` +(`DriverResiliencePipelineBuilder.cs:99–177`) adds only timeout → retry → circuit-breaker — there is +no `AddConcurrencyLimiter` (or any rate/concurrency strategy) anywhere in `src/`. The bulkhead is a +dead knob end-to-end: an operator can set it, the deploy respawns the driver, and nothing changes — +the same "configured but inert" genre as the dispatch gap this cycle just fixed, now one layer +deeper. + +**Recommendation:** either add the concurrency limiter to the pipeline (keyed like the breaker, +per host) with a factory test proving the queue bound engages, or strip the fields from the +options/parser/form and the word "bulkhead" from the docs until the strategy ships. Don't leave the +knob authored-but-unwired — this codebase's history says it will be trusted. --- ## Maturity Ratings -| Dimension | Rating (1–5) | Justification | -|---|---|---| -| Stability | **4** | Deliberate, well-commented failure-mode engineering throughout (fail-closed caches, atomic pointer swaps, detached hooks, fatal-exception classification); docked for the applier's swallow-everything success reporting (S-1) and the blocking unsubscribe (S-2). | -| Performance | **3** | Deploy-path costs are deterministic and diffed to near-zero for no-op redeploys, and the surgical-apply path avoids rebuild churn; but hot paths carry avoidable per-operation allocations (P-2), redundant JSON parsing scales 4× with tag count (P-1), and the poll engine is task-per-subscription with no coalescing (P-3). | -| Conventions | **4** | Exceptionally consistent naming, patterns, and XML docs with zero TODO debt; docked for the systemic replicate-and-comment "byte-parity" duplication (C-1/C-3) and the Core→Configuration persistence coupling (C-2). | -| Underdeveloped areas | **3** | The live pipeline is feature-complete and well-tested, but Core retains three dead/dormant code paths with green tests (U-1), a fully-built-but-unreachable Tier C recycle subsystem (U-2), and the known continuous-historization initial-set gap (U-3). | +| Dimension | 2026-07-08 | 2026-07-12 | Justification | +|---|---|---|---| +| Stability | **4** | **4** | The deliberate failure-mode engineering (fail-closed caches, detached hooks, null-object seams) still defines the domain, and the new dispatch wiring was built carefully (respawn-on-change, invalidate pairing, actor-context preservation). But nothing on the stability backlog moved — S-1's swallow-everything deploy reporting remains the headline — and the new machinery introduced a genuine operator-reachable bricking vector (S-6) that directly contradicts its own never-brick contract. | +| Performance | **3** | **3** | All four performance findings are untouched (4× JSON parse, per-node authorization garbage, task-per-subscription engine, per-write allocation). The new code is hot-path conscious (once-per-Create options snapshot, callback-free pipelines without observers) but adds no throughput improvements to the existing paths. | +| Conventions | **4** | **4** | Already exceptionally consistent; this window added *mechanical enforcement* (tree-wide OTOPCUA0001 as a build error, the reflection forwarding guard) which is the right direction — but the systemic byte-parity duplication (C-1/C-3) is untouched, the two flagged stale class narratives survived a dedicated fixdocs pass (C-7), and the new guard itself has a namespace blind spot (C-8). | +| Underdeveloped areas | **3** | **3** | The cycle's biggest dormancy (CapabilityInvoker never dispatched) is genuinely fixed and analyzer-guarded, and U-3 turned out mostly mechanically closed. But U-1's three dead code paths and U-2's implementation-less Tier C machinery are untouched — the latter now *accreting* config/validation surface — and a new authored-but-inert knob shipped (U-6 bulkhead). The pattern the review flagged (built, tested, unreachable) is still being produced. | diff --git a/archreview/02-scripting-alarms.md b/archreview/02-scripting-alarms.md index e1918095..a40b1082 100644 --- a/archreview/02-scripting-alarms.md +++ b/archreview/02-scripting-alarms.md @@ -1,14 +1,54 @@ # Architecture Review 02 — Scripting, Virtual Tags, Scripted Alarms, Alarm Historian -- **Date:** 2026-07-08 -- **Commit:** `9cad9ed0` +- **Date:** 2026-07-12 +- **Commit:** `f6eaa267` (master, clean tree) +- **Updates:** the 2026-07-08 review at `9cad9ed0`. All arch-review remediation branches have since merged to master; the delta touching this domain is exactly one commit — `7fd44f0f` (Critical 2a/2b: real VT script timeout + ALC-safe compile cache). The five in-scope Core projects are **byte-identical** to `9cad9ed0` (`git diff` empty; only test-csproj `NoWarn OTOPCUA0001` additions), so every prior Core-internal finding was re-verified against unchanged code and its line refs remain valid. - **Scope:** - `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting` + `Core.Scripting.Abstractions` (Roslyn compile/eval engine) - `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags` (virtual-tag runtime) - `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms` (scripted-alarm engine + Part 9 state machine) - `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian` (SQLite store-and-forward sink) - - Test projects under `tests/Core/` for coverage assessment -- **Method:** full read of the load-bearing files (ScriptEvaluator, ScriptSandbox, ForbiddenTypeAnalyzer, CompiledScriptCache, TimedScriptEvaluator, VirtualTagEngine, ScriptedAlarmEngine, Part9StateMachine, SqliteStoreAndForwardSink) plus their support types; production-consumer tracing into `Runtime`/`Host` to distinguish live vs dormant code; TODO/stub grep; test-breadth vs source-surface comparison. + - Production evaluation path: `Host/Engines/RoslynVirtualTagEvaluator`, `Commons/Engines/IScriptCacheOwner`, `Runtime/VirtualTags/VirtualTagHostActor` + `VirtualTagActor` (the surface the Critical-2 remediation changed) + - Test projects under `tests/Core/` + the new Host.IntegrationTests/Runtime.Tests coverage +- **Method:** verified every prior finding against the actual code (not STATUS.md claims); full adversarial read of the remediation commit (`RoslynVirtualTagEvaluator`, `IScriptCacheOwner`, `VirtualTagHostActor.OnApply`, the new tests) plus the cache/evaluator internals it now leans on (`CompiledScriptCache.Clear` concurrency, `ScriptEvaluator.Dispose`/`RunAsync` disposed-guard, `TimedScriptEvaluator` semantics); fresh-eyes pass over the changed surface. + +--- + +## Prior-finding status (9cad9ed0 → f6eaa267) + +| ID | One-liner | Status @ f6eaa267 | +|---|---|---| +| S1 | `VirtualTagEngine.Load` mutates shared state with no gate vs in-flight evals | **STILL OPEN** — `VirtualTagEngine.cs` unchanged (empty diff); engine still dormant (see U1) | +| S2 | No coalescing/backpressure on upstream-change fan-out (both engines) | **STILL OPEN** — `ScriptedAlarmEngine.cs:442-450` / `VirtualTagEngine.cs:263-272` unchanged | +| S3 | Failed reload is fail-stop, not fail-back (both engines) | **STILL OPEN** — unchanged | +| S4 | `_alarmsReferencing` plain `Dictionary` read from upstream threads without a gate | **STILL OPEN** — `ScriptedAlarmEngine.cs:446` unchanged | +| S5 | Timed-shelve expiry via the predicate path swallows the `Unshelved` emission | **STILL OPEN** — `Part9StateMachine.cs` unchanged; the missing test is still missing | +| S6 | `SqliteStoreAndForwardSink.Dispose` races an in-flight drain | **STILL OPEN** — unchanged | +| S7 | Sandbox CPU/memory unbounded; timed-out script leaks a thread; no quarantine | **STILL OPEN — blast radius grew**: the U2 fix routes production VT evaluation through the same abandon-on-timeout design, so a wedged VT script now orphans one pool thread *and* blocks its actor ~2 s per dependency change (see updated S7 body) | +| S8 | `ScriptedAlarmEngine.Dispose` blocks sync-over-async | **STILL OPEN** — `ScriptedAlarmEngine.cs:761` unchanged | +| S9 | At-least-once delivery duplicates on crash window (doc note) | **STILL OPEN** — no `docs/Historian.md` note added | +| S10 | `VirtualTagSource.SubscribeAsync` seed-then-subscribe can miss one update | **STILL OPEN** — unchanged (dormant code) | +| S11 | Capacity eviction drops oldest accepted alarm events (policy doc note) | **STILL OPEN** — no `docs/AlarmTracking.md` policy note | +| P1 | `ScriptSandbox.Build` rebuilds the full BCL reference set on every compile | **STILL OPEN** — `ScriptSandbox.cs:83-87` unchanged; now *amplified* by the per-apply cache clear (see P7) | +| P2 | `Task.Run` + `WaitAsync` per evaluation on the hot path | **STILL OPEN** (accepted) — now also the production VT cost, by design of the U2 fix | +| P3 | VT engine allocates per evaluation what the alarm engine pools | **STILL OPEN** — moot while U1 unresolved | +| P4 | Single global eval gate per engine | **STILL OPEN** (accepted) | +| P5 | SQLite per-call overheads | **STILL OPEN** (accepted) | +| P6 | `DependencyGraph` well optimized (no action) | unchanged — informational | +| C1 | `ITagUpstreamSource` defined twice | **STILL OPEN** — duplicate still at `ScriptedAlarmEngine.cs:860-872` | +| C2 | Abstractions assembly spans three namespaces undocumented | **STILL OPEN** | +| C3 | Trailing type definitions in engine files | **STILL OPEN** | +| C4 | Repeated raw OPC UA status-code literals | **STILL OPEN** | +| C5 | `Core.AlarmHistorian` mixes contract and implementation | **STILL OPEN** | +| C6 | Plan-era "Phase 7 plan Stream …" doc residue | **STILL OPEN** — `TimedScriptEvaluator.cs:5,22,39` etc. unchanged; the `3b5ef439` fixdocs sweep touched only S7/TwinCAT/Resilience files | +| C7 | `ApplyPredicate` xmldoc claims branch handling that doesn't exist | **STILL OPEN** — `Part9StateMachine.cs:31-34` unchanged | +| U1 | Entire Core.VirtualTags engine stack dormant in production | **STILL OPEN** — `new VirtualTagEngine` still appears only in its own tests; `ScriptAnalysisService.cs:293` even documents "the cascade VirtualTagEngine is dormant". No retire/sanction decision made | +| U2 | **CRITICAL** — production script timeout ineffective (`TimedScriptEvaluator` bypassed) | **FIXED** — `7fd44f0f`: `RoslynVirtualTagEvaluator.Evaluate` routes through `TimedScriptEvaluator` (`RoslynVirtualTagEvaluator.cs:101-115`); the orphan-thread trade-off is explicitly acknowledged in the comment; regression test `Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang` is itself `WaitAsync`-bounded so a regression fails instead of hanging the suite. Caveats: on the node, a timeout is a *dropped update*, not Bad quality (new S13), and the runaway thread still leaks with no quarantine (S7) | +| U3 | **HIGH** — live path bypasses `CompiledScriptCache`: unbounded ALC accretion | **FIXED** — `7fd44f0f`: raw `ConcurrentDictionary` → `CompiledScriptCache` (`RoslynVirtualTagEvaluator.cs:28`); new `IScriptCacheOwner` seam (`Commons/Engines/IScriptCacheOwner.cs`); apply-boundary clear at `VirtualTagHostActor.cs:92` on **every** `ApplyVirtualTags` (the only republish path — VT config only reaches children via this message); wiring-guard test asserts the clear per generation. Caveat: the clear races in-flight child evaluations (new S12) | +| U4 | `IHistoryWriter` is a permanently-Null surface | **STILL OPEN** — `Runtime/ServiceCollectionExtensions.cs:58,242` still binds only `NullHistoryWriter` | +| U5 | Part 9 surface gaps real but undeclared in one place | **STILL OPEN** — no conformance statement added to `docs/ScriptedAlarms.md` | +| U6 | No TODO/HACK/stub markers (healthy) | unchanged — informational | +| U7 | Test coverage broad with specific holes (a)–(e) | **PARTIALLY FIXED** — hole (e) closed: `RoslynVirtualTagEvaluatorTests` now covers the production timeout (infinite-loop + happy-path-after-wrapping + `ClearCompiledScripts`), and `VirtualTagHostActorTests` guards the apply-boundary clear wiring. Holes (a) shelve-expiry emission, (b) load-vs-cascade concurrency, (c) sink dispose-during-drain, (d) dedicated `ForbiddenTypeAnalyzer` suite — all still open | --- @@ -24,19 +64,19 @@ User scripts are C# statement bodies ending in `return …;`. `ScriptEvaluator,TResult>` delegate. `Dispose()` unloads the ALC. -Around the core evaluator sit three wrappers: `CompiledScriptCache` (SHA-256-source-keyed, `Lazy` single-compile, disposes ALCs on `Clear()` at publish-replace), `TimedScriptEvaluator` (`Task.Run` + `WaitAsync` wall-clock budget, default 250 ms, wraps timeout as `ScriptTimeoutException`; documents the known orphan-thread leak for CPU-bound scripts), and the script-log pipeline (`ScriptRootLogger` → rolling `scripts-*.log` + `ScriptLogCompanionSink` error mirror to the main log + `ScriptLogTopicSink` → DPS `script-logs` topic via `IScriptLogPublisher`). +Around the core evaluator sit three wrappers: `CompiledScriptCache` (SHA-256-source-keyed, `Lazy` single-compile, value-scoped `TryRemove` on `Clear()` so a concurrent re-add survives), `TimedScriptEvaluator` (`Task.Run` + `WaitAsync` wall-clock budget, default 250 ms, wraps timeout as `ScriptTimeoutException`; documents the known orphan-thread leak for CPU-bound scripts), and the script-log pipeline (`ScriptRootLogger` → rolling `scripts-*.log` + `ScriptLogCompanionSink` error mirror to the main log + `ScriptLogTopicSink` → DPS `script-logs` topic via `IScriptLogPublisher`). `Core.Scripting.Abstractions` holds the Roslyn-free closure the sandbox pins: `ScriptContext` (the `ctx` API — `GetTag`/`SetVirtualTag`/`Now`/`Logger`/`Deadband`), `ScriptGlobals`, `PassthroughScript` (regex fast-path classifier for the `return ctx.GetTag("X").Value;` mirror shape), and — notably — the concrete `VirtualTagContext` and `AlarmPredicateContext`, which live in this assembly but declare `Core.VirtualTags` / `Core.ScriptedAlarms` namespaces (see C2). `DependencyExtractor` statically harvests `ctx.GetTag("literal")` / `ctx.SetVirtualTag("literal", …)` call sites from the AST, rejecting dynamic paths at publish so the change-trigger subscription graph is knowable up front. -### Virtual-tag runtime (Core.VirtualTags) — two implementations, one live +### Virtual-tag runtime — two implementations, one live (now with the safety wrappers on the live one) The in-scope `VirtualTagEngine` is a self-contained multi-tag engine: `Load()` compiles all scripts through a `CompiledScriptCache`, validates data types and `SetVirtualTag` targets, builds a `DependencyGraph` (iterative Tarjan cycle detection + Kahn topo sort + cached rank), subscribes to upstream driver tags via `ITagUpstreamSource`, and serializes all evaluations under a single `SemaphoreSlim` gate. Change events cascade dependents in topological order; `TimerTriggerScheduler` adds interval-grouped timer triggers with in-flight tick skipping; `VirtualTagSource` adapts the engine to the driver-agnostic `IReadable`/`ISubscribable` surface. -**However, production does not run this engine.** The live path is `VirtualTagHostActor` → per-tag `VirtualTagActor` → `IVirtualTagEvaluator` bound to `Host/Engines/RoslynVirtualTagEvaluator` (single-tag adapter; fan-out owned by `DependencyMuxActor`). Grep confirms `VirtualTagEngine`, `TimerTriggerScheduler`, `VirtualTagSource`, and `DependencyGraph` have **no production instantiation** — they are exercised only by their unit tests (see U1). Only `VirtualTagContext`, `IHistoryWriter`/`NullHistoryWriter`, and `PassthroughScript` cross into the live path. +**Production still does not run this engine** (U1 unchanged). The live path is `VirtualTagHostActor` → per-tag `VirtualTagActor` → `IVirtualTagEvaluator` bound to `Host/Engines/RoslynVirtualTagEvaluator` (single-tag adapter; fan-out owned by `DependencyMuxActor`). **What changed at `7fd44f0f`:** the adapter now (a) routes every compiled evaluation through `TimedScriptEvaluator` (default 2 s budget; `ScriptTimeoutException` → `Failure("script timed out …")`), so a CPU-bound/infinite-loop script can no longer wedge the owning `VirtualTagActor`'s message loop; and (b) caches compiles in a `CompiledScriptCache` and implements the new `IScriptCacheOwner` capability, which `VirtualTagHostActor.OnApply` invokes at the top of every `ApplyVirtualTags` generation — stale collectible ALCs are dropped per deploy instead of accreting forever. The `PassthroughScript` mirror fast-path still bypasses Roslyn (and therefore both wrappers) — correctly, since it executes no user code. -### Scripted-alarm flow (Core.ScriptedAlarms) — live end to end +### Scripted-alarm flow (Core.ScriptedAlarms) — live end to end (unchanged) `ScriptedAlarmHostActor` (Runtime) owns a long-lived `ScriptedAlarmEngine` and calls `LoadAsync` per config apply (generation-tagged to discard stale completions). Inside the engine: @@ -45,7 +85,7 @@ The in-scope `VirtualTagEngine` is a self-contained multi-tag engine: `Load()` c 3. `Part9StateMachine` is a pure-function transition table over the immutable `AlarmConditionState` record (Enabled/Active/Acked/Confirmed/Shelving + audit `Comments` as `ImmutableList`). Operator verbs (Acknowledge/Confirm/OneShotShelve/TimedShelve/Unshelve/Enable/Disable/AddComment) come in via the engine's `ApplyAsync` (persist-before-update-in-memory; emissions built under the gate, fired after release to avoid re-entrancy deadlock). 4. Emissions (`ScriptedAlarmEvent`) fan out through `OnEvent` → `ScriptedAlarmSource` (the `IAlarmSource` adapter with equipment-path-prefix subscription filters) into the Part 9 condition nodes / `/alerts` / historian adapter, carrying the per-alarm `HistorizeToAveva` opt-out. `MessageTemplate` resolves `{TagPath}` tokens at emission with a stricter-than-predicate quality bar (Good only; else `{?}`). -### Alarm-history write path (Core.AlarmHistorian) +### Alarm-history write path (Core.AlarmHistorian) — unchanged `IAlarmHistorianSink` is the fire-and-forget ingestion contract; production is `SqliteStoreAndForwardSink`: WAL-mode SQLite `Queue` table, `EnqueueAsync` commits a row per event (capacity fast path consults an `Interlocked` cached counter, falling back to `COUNT(*)` + oldest-row eviction at the wall, with periodic 10k-enqueue resync). A self-rescheduling one-shot timer drains batches (default 100) through `IAlarmHistorianWriter` (the HistorianGateway `SendEvent` gRPC client), applying **per-event outcomes**: `Ack` → delete, `PermanentFail` → dead-letter, `RetryPlease` → attempt-bump with a max-attempts (10) poison cap; corrupt payloads dead-letter immediately so they can't stall the queue head. Backoff ladder 1→2→5→15→60 s applied to the timer due-time; dead letters retained 30 days with operator `RetryDeadLettered()`; `GetStatus()` surfaces depth/dead-letter/last-error/evictions to the Admin UI and health checks. @@ -55,6 +95,8 @@ The in-scope `VirtualTagEngine` is a self-contained multi-tag engine: `Load()` c Severity scale: **Critical** (production correctness/availability today) / **High** (real risk under realistic conditions) / **Medium** (defect or debt worth scheduling) / **Low** (note). +Fixed prior findings (U2, U3, U7-e) live only in the status table above. Still-open findings keep their IDs; new findings continue the per-category scheme (S12+, P7+, C8+). + ### 1. Stability #### S1 — HIGH — `VirtualTagEngine.Load` mutates shared state with no gate against in-flight evaluations @@ -81,9 +123,9 @@ Both engines tear down the previous generation *before* compiling the new one. O `Dispose` (`SqliteStoreAndForwardSink.cs:679-686`) sets `_disposed`, disposes the timer, then immediately disposes `_drainGate` and the writer. `Timer.Dispose()` (no `WaitHandle` overload used) does not wait for a running callback, so an in-flight `DrainOnceAsync` can still be holding the gate and mid-`WriteBatchAsync`: the `finally { _drainGate.Release(); }` (line 467) then throws `ObjectDisposedException` (caught by the timer callback's catch and logged as a drain fault), and the writer can be disposed under an active call. Not a crash — but shutdown noise and an undefined writer contract. **Recommendation:** mirror the alarm engine's dispose-drain: `_drainGate.Wait()` (or an in-flight task handle) before disposing the gate/writer, or use `Timer.Dispose(WaitHandle)`. -#### S7 — MEDIUM — Accepted sandbox limits: CPU/memory unbounded, timed-out scripts leak a thread -Documented and deliberate: the sandbox cannot bound allocation or CPU (`ScriptSandbox.cs:30-35`), and a timed-out CPU-bound script leaves its thread-pool thread spinning forever (`TimedScriptEvaluator.cs:17-26`). One orphan per bad-script evaluation *attempt* — under change-triggered evaluation a hot looping script orphans a thread per upstream change until the pool starves, since the engine keeps re-evaluating (alarm engine holds prior state on timeout, `ScriptedAlarmEngine.cs:535-538`, but does not quarantine the predicate). -**Recommendation:** add a per-script circuit breaker (N consecutive timeouts → suspend evaluation + surface to Admin UI) as a cheap interim before any out-of-process runner. The three-layer compile-time sandbox itself (allow-list refs → wrapper-injection guard → semantic deny-list with the full `TypeSyntax` pass) is **notably strong** — including the type-forwarding rationale, `var`-inference coverage via `GetTypeInfo`, and the `Unsafe`/`Activator`/`ThreadPool` type-granular entries. +#### S7 — MEDIUM — Accepted sandbox limits: CPU/memory unbounded, timed-out scripts leak a thread — **blast radius now includes the live VT path** +Documented and deliberate: the sandbox cannot bound allocation or CPU (`ScriptSandbox.cs:30-35`), and a timed-out CPU-bound script leaves its thread-pool thread spinning forever (`TimedScriptEvaluator.cs:17-26`). One orphan per bad-script evaluation *attempt* — and since `7fd44f0f` this is no longer confined to the alarm engine: production virtual-tag evaluation now runs through the same abandon-on-timeout design (`RoslynVirtualTagEvaluator.cs:101-115`), so a hot looping VT script referenced by a fast-changing dependency orphans one pool thread **and** blocks its `VirtualTagActor` dispatcher thread for the full 2 s budget *per dependency change*, with no quarantine — the actor keeps re-evaluating on every change until the pool starves. (The alarm engine holds prior state on timeout, `ScriptedAlarmEngine.cs:535-538`, but likewise does not quarantine the predicate.) That trade-off was the right call for U2 — a bounded-loss timeout strictly beats a wedged actor — but the compensating control the prior review recommended still does not exist. +**Recommendation:** add a per-script circuit breaker (N consecutive timeouts → suspend evaluation + surface to Admin UI) as a cheap interim before any out-of-process runner. The three-layer compile-time sandbox itself (allow-list refs → wrapper-injection guard → semantic deny-list with the full `TypeSyntax` pass) remains **notably strong**. #### S8 — LOW — `ScriptedAlarmEngine.Dispose` blocks sync-over-async `Task.WhenAll(toAwait).GetAwaiter().GetResult()` (`ScriptedAlarmEngine.cs:761`) — fine under the actor host (no sync context) but a deadlock trap if ever disposed from a context-bound thread. Consider `IAsyncDisposable`. @@ -97,18 +139,27 @@ Emitting the seed read *before* registering the observer (`VirtualTagSource.cs:6 #### S11 — LOW — Capacity eviction drops the *oldest* accepted alarm events Documented behavior with an `EvictedCount` operator counter (`SqliteStoreAndForwardSink.cs:602-636`). Note only: for a compliance-oriented alarm trail, dropping newest (or refusing enqueue) is sometimes preferred over silently losing the oldest; the current choice is defensible but should be a deliberate, documented policy in `docs/AlarmTracking.md`. +#### S12 — MEDIUM — **NEW (in the U3 remediation)** — Apply-boundary `ClearCompiledScripts` races in-flight child evaluations +`VirtualTagHostActor.OnApply` calls `ClearCompiledScripts()` first thing (`VirtualTagHostActor.cs:92`), on the host actor's thread — while *unchanged* child `VirtualTagActor`s keep evaluating concurrently on other dispatcher threads (nothing gates them; a `DependencyValueChanged` can land at any time). The comment says this "mirrors ScriptedAlarmEngine's per-generation clear", but the mirror is inexact: the alarm engine clears **under `_evalGate` with the old subscriptions already torn down**, so no evaluation can hold a disposed evaluator; the VT host has no gate spanning child evaluations. Concrete race: a child's `Evaluate` obtains the evaluator from the cache (`RoslynVirtualTagEvaluator.cs:69`), the host's `Clear()` then value-scoped-removes and **disposes** it (`CompiledScriptCache.cs:113-116` → `ScriptEvaluator.Dispose` sets `_disposed` + `_alc.Unload()`), and the child's subsequent `timed.RunAsync` hits the disposed guard (`ScriptEvaluator.cs:180`) — `ObjectDisposedException` → caught by the adapter's generic catch (`RoslynVirtualTagEvaluator.cs:116-120`) → `Failure("script threw: …")` → that evaluation's update is silently dropped (and per S13, quality is not degraded). A secondary wrinkle: the clear runs *before* the removed/changed children are stopped (lines 98-118), and `Context.Stop` is asynchronous — a doomed child can process one queued change after the clear and recompile its **stale** expression into the fresh cache, keeping its ALC alive until the *next* apply (bounded, one generation — not a leak, but it undercuts the "released in one sweep" intent). The cache itself is blameless: its value-scoped `TryRemove` correctly protects concurrent `GetOrCompile` re-adds; the gap is the missing coordination between clear and in-flight *use* of already-handed-out evaluators. +**Consequence:** a transient dropped evaluation per unlucky deploy (self-heals on the tag's next dependency change — which for a slow-changing tag can be a long staleness window), plus one-generation stale-ALC retention. Real but bounded — Medium. +**Recommendation:** cheapest robust fix is in the adapter: catch `ObjectDisposedException` around `RunAsync`, re-fetch via `GetOrCompile`, and retry once (the recompile is idempotent). Alternatively move the clear to *after* the stop/respawn reconciliation and have `Evaluate` re-validate, or route child evaluations through a shared `ReaderWriterLockSlim` the clear takes as writer. Add a concurrent clear-during-evaluate test either way. + +#### S13 — MEDIUM — **NEW** — Script failure/timeout never degrades the OPC UA node's quality: stale value stays Good forever +`VirtualTagActor.OnDependencyChanged` handles a failed evaluation (including the new timeout `Failure`) by publishing a Warning script-log entry + a `fail` metric and **returning without any value/quality publish** (`Runtime/VirtualTags/VirtualTagActor.cs:123-128`; same for the evaluator-threw path at 115-121). The node therefore keeps its previous state: `BadWaitingForInitialData` if the script never succeeded (acceptable), but **the last Good value with its old timestamp, indefinitely**, if a previously-working script starts failing or timing out. Data-plane consumers (OPC UA clients, the historian recorder, downstream ScadaBridge) have no way to distinguish "healthy but unchanged" from "script broken for a week" — only the script-log page and the eval metric know. Note the remediation commit message's claim that a runaway script "returns Bad within the timeout" is true of the evaluator's return value but **not** of the node. The dormant `VirtualTagEngine` gets this right — it maps script failure to `BadInternalError` quality (`VirtualTagEngine.cs:307,328`), and `ScriptEvaluator`'s own remarks (lines 40-43) promise that mapping; the live actor path never implemented it. +**Recommendation:** on `!result.Success`, publish the existing value with Bad quality (e.g. `BadInternalError` / `Uncertain` per policy) to the publish actor — the plumbing (`OpcUaPublishActor.AttributeValueUpdate` carries `OpcUaQuality`) already exists at the host-actor bridge (`VirtualTagHostActor.cs:179-180`, which currently hard-codes `Good`). Requires `EvaluationResult` to carry success/quality. Pair with a regression test: succeed once, then fail — node quality must degrade. + ### 2. Performance #### P1 — HIGH — `ScriptSandbox.Build` rebuilds the full BCL reference set on every compile -Every `ScriptEvaluator.Compile` calls `ScriptSandbox.Build(typeof(TContext))` (`ScriptEvaluator.cs:74`), which calls `MetadataReference.CreateFromFile` for **every** `System.*`/netstandard DLL in the trusted-platform-assemblies list (`ScriptSandbox.cs:83-87, 100-130`) — on the order of 100+ files, each a fresh `PortableExecutableReference` whose `AssemblyMetadata` Roslyn must load and parse per compilation. `CompiledScriptCache` de-dupes *per source*, but a publish with 200 distinct scripts pays this ~200 times, and the Admin UI `ScriptAnalysis` endpoints (which reuse this compiler context per keystroke-ish cadence) pay it continuously. The references are immutable per `contextType` for the process lifetime. +Every `ScriptEvaluator.Compile` calls `ScriptSandbox.Build(typeof(TContext))` (`ScriptEvaluator.cs:74`), which calls `MetadataReference.CreateFromFile` for **every** `System.*`/netstandard DLL in the trusted-platform-assemblies list (`ScriptSandbox.cs:83-87, 100-130`) — on the order of 100+ files, each a fresh `PortableExecutableReference` whose `AssemblyMetadata` Roslyn must load and parse per compilation. `CompiledScriptCache` de-dupes *per source*, but a publish with 200 distinct scripts pays this ~200 times, and the Admin UI `ScriptAnalysis` endpoints (which reuse this compiler context per keystroke-ish cadence) pay it continuously. The references are immutable per `contextType` for the process lifetime. **Now amplified** by the (correct) U3 apply-boundary clear: every deploy generation recompiles every VT script from scratch (see P7), each paying this cost. **Recommendation:** cache `SandboxConfig` (or at least the `MetadataReference` list) in a static `ConcurrentDictionary` keyed by `contextType.Assembly`. This is likely a 10-100× compile-latency win and directly reduces publish time and script-editor diagnostic latency. #### P2 — MEDIUM — `Task.Run` + `WaitAsync` per evaluation on the hot path -`TimedScriptEvaluator.RunAsync` (`TimedScriptEvaluator.cs:78-81`) pushes every predicate/tag evaluation to the pool and registers a timeout. For the alarm engine this happens once per referencing alarm per upstream change. The hop is required for the timeout to work at all (see U2 for what happens without it), but the common case (sub-millisecond script) pays scheduling + `WaitAsync` machinery every time. +`TimedScriptEvaluator.RunAsync` (`TimedScriptEvaluator.cs:78-81`) pushes every predicate/tag evaluation to the pool and registers a timeout. This now applies to the production VT path too (by design of the U2 fix): each `VirtualTagActor` evaluation pays a pool hop plus a synchronous block of its dispatcher thread (`RoslynVirtualTagEvaluator.cs:109` `GetAwaiter().GetResult()`). The hop is required for the timeout to work at all, and the common case (sub-millisecond script) pays scheduling + `WaitAsync` machinery every time. **Recommendation:** acceptable at current scale; if profiling ever shows it, an inline-run-with-watchdog design (run synchronously, watchdog flags overruns for quarantine rather than aborting) trades hard timeout for zero hop. #### P3 — MEDIUM — VT engine allocates per evaluation what the alarm engine pools -`VirtualTagEngine.EvaluateInternalAsync` builds a fresh read-cache `Dictionary` and a fresh `VirtualTagContext` per evaluation (`VirtualTagEngine.cs:298, 313-317`); the alarm engine got the `AlarmScratch` reuse optimization (`ScriptedAlarmEngine.cs:52-63, 798-835`) for exactly this pattern. Inconsistent — moot while the VT engine is dormant (U1), but whichever way U1 resolves, the two should match. +`VirtualTagEngine.EvaluateInternalAsync` builds a fresh read-cache `Dictionary` and a fresh `VirtualTagContext` per evaluation (`VirtualTagEngine.cs:298, 313-317`); the alarm engine got the `AlarmScratch` reuse optimization (`ScriptedAlarmEngine.cs:52-63, 798-835`) for exactly this pattern. The live `RoslynVirtualTagEvaluator` shares the allocate-per-evaluation shape (`BuildReadCache` + fresh `VirtualTagContext` + fresh `TimedScriptEvaluator` wrapper per call, `RoslynVirtualTagEvaluator.cs:87-99,108`). Inconsistent with the alarm engine — moot for the dormant engine (U1), but the live adapter is on the hot path. #### P4 — LOW — Single global eval gate per engine All evaluations serialize on one `SemaphoreSlim` (`VirtualTagEngine.cs:44`; `ScriptedAlarmEngine.cs:124`). Correct and simple; it caps throughput at single-threaded script execution per engine. Fine for operator-authored low-thousands scale (the compile cache doc states the same bound); a per-alarm or sharded gate is the escape hatch if scale demands it. Not worth changing now. @@ -119,6 +170,9 @@ All evaluations serialize on one `SemaphoreSlim` (`VirtualTagEngine.cs:44`; `Scr #### P6 — LOW — `DependencyGraph` is well optimized Cached topological rank invalidated on mutation, shared empty set for misses, iterative Tarjan/Kahn (`DependencyGraph.cs:34-44, 141-149`). The per-cascade `List`/`HashSet`/`Stack` allocations in `TransitiveDependentsInOrder` are minor. No action. +#### P7 — LOW — **NEW** — Unconditional per-apply cache flush forces full VT recompilation on every deploy, including no-op redeploys +`VirtualTagHostActor.OnApply` clears the compile cache on **every** `ApplyVirtualTags` (`VirtualTagHostActor.cs:92`) — even an identical redeploy whose plans diff equal and whose children are all left untouched. Every VT script then recompiles on its next evaluation, each paying the full P1 reference-set rebuild (5-20 ms+ per script today). The host actor already computes exactly which plans changed (the `_planByVtag` diff at lines 109-118); the clear could be conditional on any-change, or the cache could evict by retained-source set. The alarm engine has the same unconditional per-generation shape but its `LoadAsync` only runs on actual config applies. Cheap to leave as-is at current scale; fix alongside P1. + ### 3. Conventions #### C1 — MEDIUM — `ITagUpstreamSource` is defined twice @@ -133,33 +187,28 @@ Cached topological rank invalidated on mutation, shared empty set for misses, it `ScriptedAlarmEvent` and the duplicate `ITagUpstreamSource` live at the bottom of `ScriptedAlarmEngine.cs` (843-872); `CompilationErrorException` and `ScriptAssemblyLoadContext` at the bottom of `ScriptEvaluator.cs` (391-432); `DependencyCycleException` in `DependencyGraph.cs`. Mostly reasonable co-location, but `ScriptedAlarmEvent` is a public cross-project contract and deserves its own file. #### C4 — LOW — Repeated raw OPC UA status-code literals -`0x80340000u /* BadNodeIdUnknown */`, `0x80020000u /* BadInternalError */`, `0x80320000u /* BadWaitingForInitialData */`, `0x80000000u` severity mask are hand-rolled with comments in at least five files (`VirtualTagEngine.cs:215-218, 307, 328`; `VirtualTagContext.cs`; `AlarmPredicateContext.cs`; `ScriptedAlarmEngine.cs:584-587`). A small `KnownStatusCodes` static in `Core.Abstractions` would end the copy-paste drift risk (these layers deliberately avoid the OPC Foundation package, so the constants can't come from there). +`0x80340000u /* BadNodeIdUnknown */`, `0x80020000u /* BadInternalError */`, `0x80320000u /* BadWaitingForInitialData */`, `0x80000000u` severity mask are hand-rolled with comments in at least five files (`VirtualTagEngine.cs:215-218, 307, 328`; `VirtualTagContext.cs`; `AlarmPredicateContext.cs`; `ScriptedAlarmEngine.cs:584-587`; and the remediation added another — `RoslynVirtualTagEvaluator.BuildReadCache` hard-codes `StatusCode: 0u` Good, plus `VirtualTagHostActor.cs:188`'s `0u /* StatusCodes.Good */`). A small `KnownStatusCodes` static in `Core.Abstractions` would end the copy-paste drift risk (these layers deliberately avoid the OPC Foundation package, so the constants can't come from there). #### C5 — LOW — `Core.AlarmHistorian` mixes contract and implementation `IAlarmHistorianSink`/`IAlarmHistorianWriter`/status types share the project with `SqliteStoreAndForwardSink`, while scripting got a dedicated Abstractions assembly. Tolerable at three files, but the Runtime adapter and the gateway driver both reference the whole project (dragging the `Microsoft.Data.Sqlite` dependency) to get the interfaces. #### C6 — LOW — Plan-era naming residue -XML docs throughout reference "Phase 7 plan Stream A.4/B/C/E/F/G" (`TimedScriptEvaluator.cs:5, 39`; `ScriptSandbox.cs`; `IAlarmStateStore.cs:13`; many more). The repo already purged the meaningless `Phase7` *type* prefixes (master 40e8a23e); the doc references now point at plans that have been superseded by `docs/ScriptedAlarms.md`/`docs/ScriptEditor.md`. Sweep them to current doc anchors during the next doc pass. +XML docs throughout reference "Phase 7 plan Stream A.4/B/C/E/F/G" (`TimedScriptEvaluator.cs:5, 39`; `ScriptSandbox.cs`; `IAlarmStateStore.cs:13`; many more). The repo already purged the meaningless `Phase7` *type* prefixes (master 40e8a23e), and the 3b5ef439 fixdocs sweep stripped arch-review IDs elsewhere — but these plan-stream references survived both passes and now point at plans superseded by `docs/ScriptedAlarms.md`/`docs/ScriptEditor.md`. Sweep them to current doc anchors during the next doc pass. #### C7 — LOW — Doc-drift: `ApplyPredicate` claims branch handling that doesn't exist `Part9StateMachine.ApplyPredicate`'s summary promises "branch-stack increment when a new active arrives while prior active is still un-acked" (`Part9StateMachine.cs:31-34`), but no branch/previous-instance logic exists anywhere in the file — re-activation simply resets Acked/Confirmed. See U5; at minimum fix the comment. +#### C8 — LOW — **NEW** — VT script timeout is hard-coded and inconsistent with the alarm budget +Production registration passes no `runTimeout` (`Host/Program.cs:217-221`), so the live VT budget is the ctor default 2 s (`RoslynVirtualTagEvaluator.cs:45`) while alarm predicates default to `TimedScriptEvaluator.DefaultTimeout` 250 ms (`ScriptedAlarmEngine.cs:160`). Neither is operator-configurable despite the `Scripting:*` config section already existing for log settings (Program.cs:227-229) and `TimedScriptEvaluator`'s doc claiming "configurable per tag so deployments with slower backends can widen it". A `Scripting:EvaluationTimeoutMs` (or per-engine keys) would make the budgets deliberate instead of incidental — and matters more now that the timeout is load-bearing (S7/S13). + ### 4. Underdeveloped Areas #### U1 — HIGH — The entire Core.VirtualTags engine stack is dormant in production -`VirtualTagEngine` (568 lines), `TimerTriggerScheduler` (162), `VirtualTagSource` (106), and `DependencyGraph` (324) have **no production instantiation** — repo-wide grep finds `new VirtualTagEngine` only in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests`. The live virtual-tag runtime is `Runtime/VirtualTags/VirtualTagHostActor` + `DependencyMuxActor` + `Host/Engines/RoslynVirtualTagEvaluator` (single-tag adapter, `IVirtualTagEvaluator` seam), which re-implements read-cache building, cross-tag-write handling (drops them — the actor mux owns fan-out), timeout, and compile caching independently. ~1,160 lines of engine + ~1,260 lines of tests are being maintained, reviewed, and (per S1/S3) carrying latent bugs for a component nothing runs. The `RoslynVirtualTagEvaluator` doc comment even acknowledges the split ("Cycle detection + cascade ordering live in VirtualTagEngine; this adapter stays single-tag scoped"). -**Recommendation:** decide explicitly. Either (a) retire `VirtualTagEngine`/`TimerTriggerScheduler`/`VirtualTagSource` (keep `DependencyGraph` only if the publish-time cycle check uses it — verify) and let the actor pipeline be the one implementation, or (b) document the engine as the sanctioned embedded/non-Akka path and fix S1-S3. The current state — two divergent implementations where the tested one is dormant and the live one bypasses the safety wrappers (U2/U3) — is the worst of both. - -#### U2 — CRITICAL (cross-cutting; defect sits in Host, root cause is a dormant in-scope component) — Production script timeout is ineffective: `TimedScriptEvaluator` is bypassed on the live path -`RoslynVirtualTagEvaluator.Evaluate` runs `evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult()` with a 2 s `CancellationTokenSource` (`Host/Engines/RoslynVirtualTagEvaluator.cs:102-106`). But `ScriptEvaluator.RunAsync` executes the compiled delegate **synchronously on the calling thread** and only checks the token *before* invoking it (`ScriptEvaluator.cs:178-190` — "TimedScriptEvaluator wraps this in Task.Run so a long-running script still honours its wall-clock budget"). The token can therefore never interrupt a running script: the `catch (OperationCanceledException)` timeout branch in the Host adapter is dead code, and a CPU-bound or infinite-loop virtual-tag script **hangs the owning `VirtualTagActor`'s message loop indefinitely** — no timeout, no log, no recovery. The in-scope `TimedScriptEvaluator` exists precisely to prevent this and is used by the (live) alarm engine and the (dormant) VT engine — but not by the one component that evaluates operator-authored virtual-tag scripts in production. -**Recommendation:** wrap the adapter's evaluator in `TimedScriptEvaluator` (accepting the documented orphan-thread trade-off), or at minimum `Task.Run` + `WaitAsync` inside `Evaluate`. Add a regression test with a `while(true)` script. This should be fixed before any other finding in this report. - -#### U3 — HIGH (same file) — Live path also bypasses `CompiledScriptCache`: unbounded ALC accretion across republishes -`RoslynVirtualTagEvaluator` caches compiled evaluators in a raw `ConcurrentDictionary>` keyed by source (`RoslynVirtualTagEvaluator.cs:25-26, 69`), never evicted until process dispose. Every edited-script republish adds a new entry while the old source's evaluator — and its collectible `AssemblyLoadContext` — stays rooted forever. This is *exactly* the "per-publish recompile accretion" leak that `CompiledScriptCache.Clear()` was built to fix (its own doc, `CompiledScriptCache.cs:34-40`, and the engine comments at `ScriptedAlarmEngine.cs:66-77` describe the production leak from skipping it). Additionally, `GetOrAdd` with a value factory can compile twice under a race and silently leak the losing ALC (the cache solved this with `Lazy` + `ExecutionAndPublication`). -**Recommendation:** switch the adapter to `CompiledScriptCache` and clear it on config apply (the host actor already sees apply boundaries). +`VirtualTagEngine` (568 lines), `TimerTriggerScheduler` (162), `VirtualTagSource` (106), and `DependencyGraph` (324) have **no production instantiation** — repo-wide grep at f6eaa267 still finds `new VirtualTagEngine` only in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests`, and `AdminUI/ScriptAnalysis/ScriptAnalysisService.cs:293` documents the fact in passing ("the cascade VirtualTagEngine is dormant"). The live virtual-tag runtime is `Runtime/VirtualTags/VirtualTagHostActor` + `DependencyMuxActor` + `Host/Engines/RoslynVirtualTagEvaluator`. **The prior review's sharpest edge is now blunted** — since `7fd44f0f` the live adapter no longer bypasses the scope's safety wrappers (U2/U3 fixed), so the dormant engine's remaining unique value (topo-ordered cascade, cycle detection, timer triggers, `IReadable` adaptation) is squarely "unshipped feature", not "unshipped defense". That strengthens the case for retiring it: ~1,160 lines of engine + ~1,260 lines of tests still being maintained and carrying latent bugs (S1/S3/S10) for a component nothing runs. +**Recommendation:** decide explicitly. Either (a) retire `VirtualTagEngine`/`TimerTriggerScheduler`/`VirtualTagSource` (keep `DependencyGraph` only if the publish-time cycle check uses it — verify) and let the actor pipeline be the one implementation, or (b) document the engine as the sanctioned embedded/non-Akka path and fix S1-S3. Option (a) is now clearly preferable. #### U4 — MEDIUM — `IHistoryWriter` is a permanently-Null surface -`VirtualTagDefinition.Historize` routes to `IHistoryWriter.Record`, but DI binds only `NullHistoryWriter` (`Runtime/ServiceCollectionExtensions.cs:57-58, 236-237` — "durable AVEVA sink is infra-gated; a deployment binding a real IHistoryWriter overrides"). No real implementation exists in the tree; the operator-visible Historize checkbox on virtual tags is a silent no-op (the separate `ContinuousHistorization` path taps the mux directly and is itself gated + ref-set-empty per CLAUDE.md Known Limitation 2). Either wire `IHistoryWriter` to the gateway value-writer or hide/annotate the checkbox until it does something. +`VirtualTagDefinition.Historize` routes to `IHistoryWriter.Record`, but DI binds only `NullHistoryWriter` (`Runtime/ServiceCollectionExtensions.cs:57-58, 241-242` — "durable AVEVA sink is infra-gated; a deployment binding a real IHistoryWriter overrides"). No real implementation exists in the tree; the operator-visible Historize checkbox on virtual tags is a silent no-op (the separate `ContinuousHistorization` path taps the mux directly). Either wire `IHistoryWriter` to the gateway value-writer or hide/annotate the checkbox until it does something. #### U5 — MEDIUM — Part 9 surface gaps are real but undeclared in one place No condition branches / previous-instances (re-activation while un-acked just resets Acked — and the xmldoc overpromises, C7); `Severity` is static per definition ("not currently computed by the predicate", `ScriptedAlarmDefinition.cs`); `AlarmKind` affects only node typing; `MessageTemplate` has no brace escaping (documented as "reach for a feature request", `MessageTemplate.cs:16-19`); `Retain` is carried on the definition but not consulted anywhere in this scope. Each is a defensible v1 cut; collect them in `docs/ScriptedAlarms.md` as an explicit conformance statement so OPC UA client integrators know what subset to expect. @@ -167,30 +216,31 @@ No condition branches / previous-instances (re-activation while un-acked just re #### U6 — LOW — No TODO/HACK/stub markers Grep found zero `TODO|HACK|FIXME|NotImplemented` across all five projects — unfinished edges are instead documented in XML remarks (a healthier pattern, though it makes gaps greppable only by reading). -#### U7 — LOW — Test coverage: broad and behavior-focused, with specific holes -~6,690 test lines against ~5,760 source lines, all four impl projects have dedicated suites: `SqliteStoreAndForwardSinkTests` (888 — outcomes, poison cap, corrupt payloads, capacity fast path), `ScriptedAlarmEngineTests` (1,270), `ScriptSandboxTests` (614 — the escape catalog), `CompiledScriptCacheTests` (409 — including ALC-reclaim via `WeakReference`), `VirtualTagEngineTests` (800), state machine, extractor, graph, loggers, sources. Holes worth filling: (a) S5's expiry-during-predicate emission swallow — no `Part9StateMachineTests` case; (b) no concurrency test for load-vs-cascade (would expose S1/S4); (c) no sink `Dispose`-during-drain test (S6); (d) `ForbiddenTypeAnalyzer` has no dedicated unit suite (exercised only through end-to-end `Compile` in sandbox tests — fine functionally, but analyzer-pass regressions produce opaque failures); (e) nothing tests the *production* `RoslynVirtualTagEvaluator` timeout (which would have caught U2 — it lives in Host, whose test project should own it). +#### U7 — LOW (was MEDIUM-leaning) — Test coverage: broad and behavior-focused, with specific holes +~6,690 test lines against ~5,760 source lines, all four impl projects have dedicated suites: `SqliteStoreAndForwardSinkTests` (888 — outcomes, poison cap, corrupt payloads, capacity fast path), `ScriptedAlarmEngineTests` (1,270), `ScriptSandboxTests` (614 — the escape catalog), `CompiledScriptCacheTests` (409 — including ALC-reclaim via `WeakReference`), `VirtualTagEngineTests` (800), state machine, extractor, graph, loggers, sources. **Closed since the prior review:** hole (e) — the production `RoslynVirtualTagEvaluator` timeout is now covered (`Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`: `WaitAsync`-bounded infinite-loop test, happy-path-after-wrapping, `ClearCompiledScripts` emptying), and the apply-boundary clear has a production-caller wiring guard (`Runtime.Tests/VirtualTags/VirtualTagHostActorTests.ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation` — two generations, proving per-generation not one-shot). Remaining holes: (a) S5's expiry-during-predicate emission swallow — no `Part9StateMachineTests` case; (b) no concurrency test for load-vs-cascade (would expose S1/S4) — and now also none for clear-vs-in-flight-evaluate (would expose S12); (c) no sink `Dispose`-during-drain test (S6); (d) `ForbiddenTypeAnalyzer` has no dedicated unit suite (exercised only through end-to-end `Compile` in sandbox tests); (f — new) nothing asserts node quality degrades on persistent script failure (would expose S13). --- ## Maturity Ratings -Scale: 1 (prototype) → 5 (production-hardened). Rating reflects the scoped code as it stands, including how it is (or isn't) consumed. +Scale: 1 (prototype) → 5 (production-hardened). Rating reflects the scoped code as it stands, including how it is (or isn't) consumed. Old rating = 2026-07-08 @ 9cad9ed0. -| Dimension | Rating | Justification | +| Dimension | Old → New | Justification | |---|---|---| -| Stability | **3.5** | Alarm engine + sink show genuinely careful engineering (gate discipline, emit-outside-gate, dispose drains, persist-before-memory, poison-row caps); dragged down by fail-stop reloads, unbounded change fan-out, the VT engine's un-gated `Load`, and the shelve-expiry emission swallow. | -| Performance | **3** | Right structures where it counts (compile cache, alarm scratch reuse, graph rank cache, SQLite capacity fast path), but every compile rebuilds the full BCL reference set (P1), every evaluation pays a pool hop, and one global gate serializes each engine. | -| Conventions | **4** | Strongly consistent with repo norms (Serilog, Null-object defaults, xUnit/Shouldly, exemplary "why"-comments); duplicated `ITagUpstreamSource`, the three-namespace Abstractions assembly, and literal status codes are the debt. | -| Underdeveloped areas | **2** | A complete, tested virtual-tag engine nobody runs, while the live path bypasses the scope's two key defenses (timeout → hung actor risk, ALC cache → leak); Historize is a Null no-op; Part 9 gaps undeclared. The split between "what is built and tested" and "what actually runs" is the defining problem of this subsystem. | +| Stability | **3.5 → 3.5** | The worst live-path availability risk (a hung `VirtualTagActor`) is gone and the fix carries real, hang-proof regression tests — but every Core-engine stability finding (S1-S11) is untouched, and the remediation itself introduced two moderates: the apply-boundary clear races in-flight evaluations (S12), and failure/timeout never reaches node quality so stale values stay Good (S13). Net: same grade, different failure modes — bounded-loss instead of wedged. | +| Performance | **3 → 3** | P1 (per-compile BCL reference-set rebuild) still unfixed and now amplified by the unconditional per-apply cache flush (P7); the hot path additionally pays the (accepted) pool hop + actor-thread block. Right structures elsewhere unchanged. | +| Conventions | **4 → 4** | The remediation code follows repo norms well (why-comments, narrow capability seam mirroring the repo's optional-interface pattern, Null-object-safe cast). C1-C7 all still open; C8 (hard-coded, inconsistent timeout budgets) is new but minor. | +| Underdeveloped areas | **2 → 3** | The dimension's defining problem — "the live path bypasses the scope's two key defenses" — is fixed and guard-tested (U2/U3), and the production timeout finally has tests (U7-e). Still holding it below 4: the dormant 1,160-line engine remains unretired with its latent bugs (U1), Historize is still a silent Null no-op (U4), and the Part 9 conformance statement still doesn't exist (U5). | --- ## Priority Recommendations (ordered) -1. **U2** — Wrap the production virtual-tag evaluation in `TimedScriptEvaluator` (or equivalent). A single bad script can currently hang a `VirtualTagActor` forever with no timeout. -2. **U3** — Use `CompiledScriptCache` (with apply-boundary `Clear`) in `RoslynVirtualTagEvaluator` to stop cross-publish ALC accretion. -3. **P1** — Statically cache the `ScriptSandbox` reference set; large publish-time and script-editor latency win for one small change. -4. **U1** — Decide the fate of the dormant `VirtualTagEngine` stack (retire preferred); until then S1/S3 fixes are latent-bug maintenance on dead code. -5. **S2** — Add dirty-set coalescing to `ScriptedAlarmEngine` upstream fan-out before a fast tag meets a popular alarm in production. -6. **S5 + C7** — Fix the timed-shelve expiry emission and the state-machine doc drift together, with a state-machine test. -7. **S3/S4/S6** — Schedule the reload-swap, `_alarmsReferencing` synchronization, and sink dispose-drain as one hardening pass. +1. **S13** — Propagate script failure/timeout to node quality (the `AttributeValueUpdate` plumbing already carries `OpcUaQuality`; the bridge hard-codes Good). Without it, the new timeout converts a visible hang into an *invisible* stale-Good value — arguably a worse operator experience for a persistent fault. +2. **S12** — Close the clear-vs-in-flight-evaluate race in `RoslynVirtualTagEvaluator` (catch-`ObjectDisposedException`-recompile-retry is a 10-line fix), with a concurrency test. +3. **P1 (+P7)** — Statically cache the `ScriptSandbox` reference set; the per-apply cache flush makes every deploy pay the full rebuild for every VT script, so this is now a deploy-latency issue, not just editor latency. +4. **U1** — Retire the dormant `VirtualTagEngine` stack. With U2/U3 fixed, it no longer holds any defense the live path lacks; keeping it means maintaining S1/S3/S10 in dead code. +5. **S7** — Per-script circuit breaker (N consecutive timeouts → quarantine + Admin UI surface): the timeout now protects both engines, but each timed-out attempt still orphans a thread and (VT path) blocks an actor 2 s — a hot dependency on a wedged script is a slow pool-starvation vector. +6. **S2** — Add dirty-set coalescing to `ScriptedAlarmEngine` upstream fan-out before a fast tag meets a popular alarm in production. +7. **S5 + C7** — Fix the timed-shelve expiry emission and the state-machine doc drift together, with a state-machine test. +8. **S3/S4/S6** — Schedule the reload-swap, `_alarmsReferencing` synchronization, and sink dispose-drain as one hardening pass. diff --git a/archreview/03-server-runtime.md b/archreview/03-server-runtime.md index 91855927..b6c37ceb 100644 --- a/archreview/03-server-runtime.md +++ b/archreview/03-server-runtime.md @@ -1,163 +1,200 @@ # Architecture Review — Server & Runtime Subsystem -- **Date:** 2026-07-08 -- **Commit:** `9cad9ed0` -- **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer`, `.Host`, `.Runtime`, `.ControlPlane`, `.Security`, plus the sink/publisher wrappers in `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa` (they are the load-bearing seam between Host and Runtime) and `tests/Server` for coverage assessment. +- **Date:** 2026-07-12 +- **Commit:** `f6eaa267` (master, clean tree) +- **Updates:** the 2026-07-08 review at `9cad9ed0`. Since then all arch-review remediation branches merged to master (~40 commits), including the S1 SBR work + #11 premise correction, the #9 hard-kill failover test, the U2 deferred-sink reflection guard, and the #10/#13 CapabilityInvoker dispatch wiring + per-instance ResilienceConfig. +- **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer`, `.Host`, `.Runtime`, `.ControlPlane`, `.Security`, plus the sink/publisher wrappers in `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa`, cluster/SBR behavior in `src/Core/ZB.MOM.WW.OtOpcUa.Cluster`, and `tests/Server` for coverage assessment. - **Dimensions:** Stability · Performance · Conventions · Underdeveloped areas --- +## Prior-finding status (9cad9ed0 → f6eaa267) + +Every finding from the 2026-07-08 review, verified against the actual code (not just STATUS.md). Production code in `OpcUaServer`, `ControlPlane`, `Security`, and `Commons/OpcUa` is **byte-identical** since 9cad9ed0 (only `Host/Drivers/DriverFactoryBootstrap.cs`, `Host/Engines/*`, `Runtime/Drivers/*`, `Runtime/ServiceCollectionExtensions.cs`, and `Cluster/*` changed), so most findings carry over verbatim with shifted line numbers noted. + +| ID | One-liner | Verified status | +|---|---|---| +| S1 | SBR "configured but never activated"; cluster runs NoDowning | **INVALID PREMISE, GAP CLOSED ANYWAY (#11 + #9).** The original Critical was factually wrong: Akka.Cluster.Hosting's `WithClustering` applies `SplitBrainResolverOption.Default` when the typed option is null, which registers the SBR downing provider that reads the pre-existing `akka.conf` `keep-oldest` block — so SBR **was already active on master** and hard-crash failover already worked; the cluster was never NoDowning. Proven by the #9 negative control (only an explicit `downing-provider-class = ""` breaks failover). What *was* real: the strategy was implicit-in-framework-default and hard-kill failover was untested. Both closed: `BuildClusterOptions` now sets `KeepOldestOption { DownIfAlone = true }` explicitly (`Cluster/ServiceCollectionExtensions.cs:108-116`, `a81dea10`) with corrected-premise docs in the XML comment + `akka.conf:40-47` (`eaf78aad`), and `HardKillFailoverTests` + `TwoNodeClusterHarness.HardKillNodeAAsync` (transport shutdown → B downs the alone oldest and takes over as `driver` role-leader, ~35 s, stable-streak polled) guard the failover *outcome* regardless of activation path (`a25c9ed0`). The Failover trait is not excluded by the CI filter (`v2-ci.yml:61`), so it runs in the whole-solution leg. | +| S2 | LDAP auth blocks SDK session-activation thread, no timeout | **STILL OPEN.** `OpcUaApplicationHost.cs:271-274` still block-bridges with `CancellationToken.None`; `LdapOpcUaUserAuthenticator.cs:30-48` adds no timeout; `LdapOptions.ToLibraryOptions()` (`Security/Ldap/LdapOptions.cs:94-116`) still projects no timeout option. No remediation branch existed. | +| S3 | HistoryRead block-bridges gateway per node, sequentially | **STILL OPEN.** `OtOpcUaNodeManager.cs:1911, 2059, 2171, 2203` unchanged — per-node `.GetAwaiter().GetResult()` with `CancellationToken.None`, bounded only by the 30 s gateway `CallTimeout` against `MaxRequestThreadCount = 100`. | +| S4 | Primary-gate default-allow while role unknown (dual-primary window) | **STILL OPEN.** `DriverHostActor.cs:977` (alarm emit), `:1039` (write gate), `:1095` (ack gate) still gate on `_localRole is Secondary or Detached` with null → allow; `OnRedundancyStateChanged` `:1126`. Listed in STATUS.md's "suggested next", not started. | +| S5 | Redundancy NodeId string-match, no mismatch diagnostic | **STILL OPEN.** `OpcUaPublishActor.cs:512` unchanged; no log when the local node is absent from a snapshot. | +| S6 | Driver-child restart loses desired-subscription state; no backoff supervision | **STILL OPEN.** Repo-wide grep: still zero `SupervisorStrategy` overrides in Runtime/ControlPlane; desired-subs still arrive post-spawn into actor state (`DriverInstanceActor.cs:84, 190-203, 912`). Still no supervision/restart test. | +| S7 | `PostStop` blocks on `ShutdownAsync(CancellationToken.None)` | **STILL OPEN.** Now at `DriverInstanceActor.cs:1009`. | +| S8 | Part 9 commands / native acks at-most-once, client sees Good | **STILL OPEN.** `OtOpcUaServerHostedService.cs:139-194` routers unchanged; no drop metric. | +| S9 | No server-cert renewal/expiry monitoring | **STILL OPEN.** `OpcUaApplicationHost.cs:305-317` unchanged. | +| S10 | SDK start failure swallowed; node runs with Null sinks, invisible to /health | **STILL OPEN.** `OtOpcUaServerHostedService.cs:110-129` unchanged. | +| S11 | `AuditWriterActor` unbounded buffer, drops batches on outage | **STILL OPEN** (and still moot — see U3). | +| P1 | Any structural deploy change → full rebuild severs all monitored items | **STILL OPEN.** `AddressSpaceApplier.cs:134-154` / `OtOpcUaNodeManager.cs:1690-1736` unchanged. The U2 reflection guard (now merged) was the declared prerequisite gate for the surgical pure-adds work; the work itself has not started. | +| P2 | Per-value global-lock write, no batching | **STILL OPEN.** `OtOpcUaNodeManager.cs:266-281` unchanged. Same guard-unblocked, not-started status as P1. | +| P3 | HistoryRead-Events unpaged, `0` → `int.MaxValue` | **STILL OPEN.** `OtOpcUaNodeManager.cs:1914-1954` unchanged. | +| P4 | Deploy re-runs four Materialise passes (low) | **STILL OPEN.** `OpcUaPublishActor.cs:338-354` unchanged. | +| C1 | `ServerHistorian` bound imperatively in five places | **STILL OPEN.** `Program.cs:121`, `Runtime/ServiceCollectionExtensions.cs:132, 168` (+ `AddAlarmHistorian`), `OtOpcUaServerHostedService.cs:89` — all unchanged. | +| C2 | Two-tier options validation; `DevStubMode` only log-warned in prod | **STILL OPEN.** `OtOpcUaLdapAuthService.cs:79-88` unchanged. | +| C3 | Static Serilog logger islands + node-manager CS0618 pragmas | **STILL OPEN.** `Runtime/ServiceCollectionExtensions.cs:90, 100, 136` unchanged. (The *new* resilience code in `DriverFactoryBootstrap` correctly uses `ILoggerFactory` — the convention is honored by new code, not retrofitted.) | +| C4 | `IHistorianProvisioning` resolve has no missing-registration warning | **STILL OPEN.** Now `Runtime/ServiceCollectionExtensions.cs:303` (shifted +5). The new invoker-factory resolve (`:219-220`) is likewise silent, but deliberately so (admin nodes legitimately lack it) and is backstopped by the new DI guard test — see positive notes. | +| U1 | CLAUDE.md "KNOWN LIMITATION 2" stale (ref-feed gap closed in code) | **FIXED.** Rewritten on the docs branch (`9fadead6`, merged `b67bd9e8`): now reads "value-capture path is code-complete… treat continuous value-capture as unproven-in-production rather than absent". The residual recommendation (explicit restart-convergence test) remains open — folded into U6. | +| U2 | Deferred-sink forwarding trap half test-guarded | **FIXED.** `DeferredSinkForwardingReflectionTests` (`tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/`, `a65c2ced`, merged `1d7e7caa`): reflects over every method of every forwarding interface of `DeferredAddressSpaceSink` + `DeferredServiceLevelPublisher`, invokes each against a `DispatchProxy` recording inner (auto-covers future members), asserts arrival, plus a guard-of-the-guard pinning the wrapper's interface set. Negative control verified per STATUS. This retires the F10b/PR#423 trap class and unblocks P1/P2. | +| U3 | Structured audit pipeline has zero producers | **STILL OPEN.** `AuditOutcomeMapper.cs:12-18` / `Security/Audit/AuditActor.cs:18-24` unchanged. | +| U4 | `FleetStatusBroadcaster` heartbeat dead code + host-only NodeId key | **STILL OPEN.** `FleetStatusBroadcaster.cs:38, 110-121, 152-159` unchanged. | +| U5 | Native Part 9 conditions Acknowledge-only to driver | **STILL OPEN.** `OtOpcUaNodeManager.cs:647-658, 698-703` unchanged. | +| U6 | Test gaps concentrate on the fragile seams | **PARTIALLY FIXED.** Closed: hard-kill failover (#9, the biggest single blind spot); whole-solution CI leg + fail-on-skip gate + CLI deflake (`10b89830`, `v2-ci.yml:58-61` + `scripts/ci/assert-not-all-skipped.sh`) — green-because-all-skipped is now detected. Still open: DPS delivery of redundancy/ServiceLevel state untested over a real mediator; no actor supervision/restart test (S6); outbox restart-durability untested; recorder restart-convergence untested (from U1); `OpcUaServer.IntegrationTests` still contains exactly one test (`DualEndpointTests`). | +| U7 | `IHistoryWriter` Null-wired; H2 HistoryUpdate unimplemented; `LdapAuthFailure` overload | **STILL OPEN** (tracked backlog). | +| U8 | `BuildSecurityPolicies` doc claims a log that doesn't exist | **STILL OPEN.** `OpcUaApplicationHost.cs:376-418` unchanged. | +| U9 | `EnsureVariable` historize-intent invariant lives in comments | **STILL OPEN.** `OtOpcUaNodeManager.cs:1345-1349` unchanged. | + +--- + ## Architecture overview ### Host composition `Program.cs` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) is a single fused binary whose wiring is driven by the `OTOPCUA_ROLES` env var (`admin`, `driver`, or both). Notable mechanics: per-role appsettings overlays with env-vars/args re-appended so deployment overrides keep precedence (`Program.cs:61-73`); CWD anchored to `AppContext.BaseDirectory` for Windows-service path sanity (`:83`); Serilog via the shared `AddZbSerilog` with the static `Log.Logger` assigned post-build so Akka's `SerilogLogger` routes actor logs (`:305`). -On `driver` nodes the host registers the historian stack (three config-gated layers: `AddServerHistorian` read client, `AddAlarmHistorian` SQLite store-and-forward, `AddHistorianProvisioning` EnsureTags, plus the ContinuousHistorization outbox/writer pair), the driver-factory registry (8 drivers, `DriverFactoryBootstrap.cs`), the LDAP data-plane authenticator, and — critically — the **late-binding seams**: `DeferredAddressSpaceSink` and `DeferredServiceLevelPublisher` are DI singletons that actors capture at construction; `OtOpcUaServerHostedService` swaps the real SDK-backed implementations in after `StandardServer` start and swaps Nulls back in on stop (`OtOpcUaServerHostedService.cs:131, 226-231, 241-253`). +On `driver` nodes the host registers the historian stack (three config-gated layers plus the ContinuousHistorization outbox/writer pair), the driver-factory registry (8 drivers, `DriverFactoryBootstrap.cs`), **the Phase 6.1 resilience infrastructure** (new since 9cad9ed0: process-singleton `DriverResilienceStatusTracker` + `DriverResiliencePipelineBuilder` + the `IDriverCapabilityInvokerFactory` closing over `DriverFactoryRegistry.GetTier`, `DriverFactoryBootstrap.cs:53-71`), the LDAP data-plane authenticator, and the late-binding seams: `DeferredAddressSpaceSink` / `DeferredServiceLevelPublisher` are DI singletons that actors capture at construction; `OtOpcUaServerHostedService` swaps real SDK-backed implementations in after `StandardServer` start and Nulls back on stop (`OtOpcUaServerHostedService.cs:131, 226-231, 241-253`). ### OPC UA server & node manager -`OtOpcUaSdkServer` (a `StandardServer`) creates the single `OtOpcUaNodeManager` (`CustomNodeManager2`, 2 424 lines) that owns the entire writable address space: UNS Area/Line/Equipment folders, typed equipment-tag variables, Part 9 `AlarmConditionState` nodes, historized-node registration, and four HistoryRead override arms (Raw with synthesized continuation-point paging + tie-cluster over-fetch, Processed, AtTime, Events). Reverse paths out of the node manager are deliberately Akka-free delegates/interfaces set by the Host at start: `AlarmCommandRouter` (→ DPS `alarm-commands` topic), `NativeAlarmAckRouter` (→ `DriverHostActor.RouteNativeAlarmAck`), `NodeWriteGateway` (→ `ActorNodeWriteGateway` Ask), `HistorianDataSource` (→ gateway read client). Inbound writes are role-gated (`WriteOperate`), dispatched fire-and-forget under the SDK `Lock`, optimistically applied, and self-corrected (Bad-quality blip + revert + Part 8 audit event) on a failed device outcome. +Unchanged since 9cad9ed0. `OtOpcUaSdkServer` (a `StandardServer`) creates the single `OtOpcUaNodeManager` (`CustomNodeManager2`, 2 424 lines) owning the entire writable address space: UNS folders, typed equipment-tag variables, Part 9 `AlarmConditionState` nodes, historized-node registration, four HistoryRead override arms. Reverse paths are Akka-free delegates set by the Host at start (`AlarmCommandRouter`, `NativeAlarmAckRouter`, `NodeWriteGateway`, `HistorianDataSource`). Inbound writes are role-gated (`WriteOperate`), dispatched fire-and-forget under the SDK `Lock`, optimistically applied, self-corrected on failed device outcome. ### Runtime actor topology (per driver node) -`WithOtOpcUaRuntimeActors` (`Runtime/ServiceCollectionExtensions.cs:194-347`) spawns, in order: `DbHealthProbeActor` → `DependencyMuxActor` → (gated) `ContinuousHistorizationRecorder` → `OpcUaPublishActor` (pinned single-thread dispatcher `opcua-synchronized-dispatcher`, owns the `AddressSpaceApplier`) → `PeerProbeSupervisor` → `DriverHostActor` → `HistorianAdapterActor`. `DriverHostActor` spawns one `DriverInstanceActor` per deployed driver plus the `VirtualTagHostActor` and `ScriptedAlarmHostActor` sub-hosts, maintains the `(driver, FullName) ⇄ NodeId` routing maps, and fans every `AttributeValuePublished` to the mux + the publish actor (`ForwardToMux`, `DriverHostActor.cs:561-589`). **No actor in Runtime overrides `SupervisorStrategy`** — everything runs under Akka's default one-for-one unlimited restart decider. +`WithOtOpcUaRuntimeActors` (`Runtime/ServiceCollectionExtensions.cs:196-357`) spawns: `DbHealthProbeActor` → `DependencyMuxActor` → (gated) `ContinuousHistorizationRecorder` → `OpcUaPublishActor` (pinned dispatcher, owns `AddressSpaceApplier`) → `PeerProbeSupervisor` → `DriverHostActor` → `HistorianAdapterActor`. `DriverHostActor` spawns one `DriverInstanceActor` per deployed driver plus the VirtualTag/ScriptedAlarm sub-hosts. + +**New since 9cad9ed0 — the resilience dispatch seam (#10/#13):** `DriverInstanceActor` now routes all **six** of its driver-capability call sites through an injected `IDriverCapabilityInvoker` (Core.Abstractions; Runtime stays deliberately Polly-free — verified: the csproj still references only `Core.Abstractions`, never `Core`): Write via `ExecuteWriteAsync` (`DriverInstanceActor.cs:597`), alarm-ack `AlarmAcknowledge` (`:650`), subscribe/unsubscribe `Subscribe` (`:693, :722`), alarm-subscribe `AlarmSubscribe` (`:803`), discover `Discover` (`:868`). Single-reference calls key the per-host breaker via `IPerCallHostResolver` (`:957`; implemented by Modbus/AbCip/AbLegacy/FOCAS/TwinCAT with a safe fall-back-to-instance-id); bulk calls key on the instance id. Per-instance `ResilienceConfig` from the deploy artifact (`DeploymentArtifact.cs:171-185`) is layered onto tier defaults by `DriverCapabilityInvokerFactory.Create`, which also `Invalidate`s the instance's cached pipelines so a respawn with changed options rebuilds them; `DriverSpawnPlanner` forces stop+respawn on a ResilienceConfig change while a pure DriverConfig change stays an in-place delta (`DriverSpawnPlan.cs:45-56`). Generation-suffixed actor names (`DriverHostActor.cs:1666-1670`, pre-existing) make the stop→respawn name-collision-safe, and the apply path re-pushes desired subscriptions after reconcile (`:1215-1225`), so a respawned child converges. The OTOPCUA0001 analyzer (wired tree-wide via `Directory.Build.props:48`) is the compile-time guard that every guarded call stays wrapped; `ResilienceInvokerFactoryRegistrationTests` guards that production DI binds the *real* factory, not the pass-through. + +**No actor in Runtime overrides `SupervisorStrategy`** — everything still runs under Akka's default one-for-one unlimited restart decider (S6). ### Redundancy model -Non-transparent warm redundancy. `RedundancyStateActor` (admin-role cluster singleton, `ControlPlane/Redundancy/RedundancyStateActor.cs`) derives Primary/Secondary purely from Akka's `driver` role-leader, debounces 250 ms, publishes `RedundancyStateChanged` on the DPS `redundancy-state` topic, and re-publishes on a 10 s heartbeat (the late-subscriber fix). Consumers: `OpcUaPublishActor` computes ServiceLevel (`ServiceLevelCalculator` + DB-health probe + peer OPC UA probes; 240/200/100/0 bands, first-publish-always so the SDK's 255 default never stands — `OpcUaPublishActor.cs:418-437, 508-542`); `DriverHostActor`, `ScriptedAlarmHostActor`, and `HistorianAdapterActor` use the same snapshot to Primary-gate device writes, alarm emission, and historization. There is no lease or fencing token — correctness rests entirely on Akka's single-role-leader guarantee within a converged cluster. +Non-transparent warm redundancy, unchanged in code; the *understanding* of it is corrected (S1/#11). `RedundancyStateActor` derives Primary/Secondary from Akka's `driver` role-leader, debounces 250 ms, publishes on DPS `redundancy-state`, re-publishes on a 10 s heartbeat. The split-brain resolver is **active and now explicit**: `BuildClusterOptions` sets `KeepOldestOption { DownIfAlone = true }` (correct for a 2-node pair; `keep-majority`/`static-quorum` would be wrong), consistent with the `akka.conf` block (`stable-after = 15s`, which only HOCON can express). Hard-crash failover is proven by `HardKillFailoverTests` (~35 s in-process). There is still no lease or fencing token — correctness rests on Akka's single-role-leader guarantee within a converged cluster, and the boot-window default-allow (S4) remains the data-plane soft spot. --- ## Findings -Severity scale: **Critical** (correctness/availability broken in a realistic scenario) · **High** (significant risk or debt, plan a fix) · **Medium** (real but bounded) · **Low** (hygiene). +Severity scale: **Critical** (correctness/availability broken in a realistic scenario) · **High** (significant risk or debt, plan a fix) · **Medium** (real but bounded) · **Low** (hygiene). Fixed/invalidated findings live only in the status table above; still-open findings keep their IDs; new findings continue the scheme. ### 1. Stability -**S1 — CRITICAL: The split-brain resolver is configured but never activated; unreachable members are never downed.** -`akka.conf` carries a full `split-brain-resolver { active-strategy = "keep-oldest" … }` block (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf:40-46`), but there is **no `akka.cluster.downing-provider-class` anywhere in the repo**, and `WithClustering` sets only `SeedNodes`/`Roles` — not `ClusterOptions.SplitBrainResolver` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs:80-84`; repo-wide grep for `downing-provider|SplitBrainResolver` returns nothing). In Akka.NET the SBR HOCON block is inert without the provider, so the cluster runs the default **NoDowning** provider. Consequences: (a) a *hard-crashed* node stays "unreachable" forever, so **cluster singletons (`ConfigPublishCoordinator`, `AdminOperationsActor`, `RedundancyStateActor`, `AuditWriterActor`) never fail over** and the `driver` role-leader never moves — i.e. redundancy failover works only for graceful shutdowns; (b) a network partition never resolves, and with `min-nr-of-members = 1` each side elects its own role-leader, so **both sides advertise ServiceLevel 240 indefinitely**. The two-node integration harness only exercises graceful `StopAsync`, which is why this is invisible in tests. -*Recommendation:* set `ClusterOptions.SplitBrainResolver = SplitBrainResolverOption` (or `downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider"` in `akka.conf`), then add a two-node kill-9 failover test asserting singleton handover + ServiceLevel demotion. - **S2 — HIGH: LDAP authentication blocks the OPC UA session-activation thread with an effectively non-cancellable, non-configurable timeout.** -The SDK invokes `ImpersonateUser` synchronously; `OpcUaApplicationHost.HandleImpersonation` block-bridges the authenticator (`OpcUaApplicationHost.cs:271-273`) with `CancellationToken.None`. The doc comment shifts timeout responsibility to the authenticator ("callers must enforce their own timeouts"), but `LdapOpcUaUserAuthenticator` adds none (`Host/OpcUa/LdapOpcUaUserAuthenticator.cs:30-48`), and the shared library's `AuthenticateAsync` is a **synchronous method wrapped in `Task.FromResult`** that observes the token only at entry; its 10 s connect timeout is a library default that OtOpcUa's `LdapOptions.ToLibraryOptions()` does not project or expose (`Security/Ldap/LdapOptions.cs:94-107`). Every authentication also opens a fresh TCP connection + service-account bind + search + user bind (no pooling). Under a directory outage or slow DC, session activations stall serially for ~10-20 s each on SDK threads. -*Recommendation:* enforce a hard timeout in `LdapOpcUaUserAuthenticator` (`Task.WhenAny` + deny on timeout), surface a `Security:Ldap:TimeoutMs` option, and consider a short negative-cache to shed load during an outage. Fail-closed semantics are otherwise correct end-to-end (deny on error, opaque messages, zero-role fallback on mapper fault). +The SDK invokes `ImpersonateUser` synchronously; `OpcUaApplicationHost.HandleImpersonation` block-bridges the authenticator (`OpcUaApplicationHost.cs:271-274`) with `CancellationToken.None`. The doc comment shifts timeout responsibility to the authenticator, but `LdapOpcUaUserAuthenticator` adds none (`Host/OpcUa/LdapOpcUaUserAuthenticator.cs:30-48`), and the shared library's `AuthenticateAsync` is synchronous-in-`Task.FromResult`; its 10 s connect timeout is a library default that `LdapOptions.ToLibraryOptions()` does not project or expose (`Security/Ldap/LdapOptions.cs:94-116`). Every authentication opens a fresh TCP connection + service-account bind + search + user bind. Under a directory outage, session activations stall serially ~10-20 s each on SDK threads. +*Recommendation:* enforce a hard timeout in `LdapOpcUaUserAuthenticator` (`Task.WhenAny` + deny on timeout), surface `Security:Ldap:TimeoutMs`, consider a short negative-cache. Fail-closed semantics are otherwise correct end-to-end. **S3 — HIGH: HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads.** -All four HistoryRead arms call `.GetAwaiter().GetResult()` per node handle with `CancellationToken.None` (`OtOpcUaNodeManager.cs:1902-1911, 2059, 2169-2171, 2201-2203`). This is safe w.r.t. the node-manager `Lock` (the overrides run outside it — correctly documented), but bounded only by the gateway client's per-call `CallTimeout` (30 s default). A single request naming N historized nodes against a slow historian holds one SDK request thread up to N × 30 s; the server is configured `MaxRequestThreadCount = 100`, `MaxQueuedRequestCount = 200` (`OpcUaApplicationHost.cs:329-331`), so a handful of misbehaving history clients can exhaust the request pool and degrade *all* OPC UA services. -*Recommendation:* parallelize per-node reads within a batch (bounded), pass a per-request deadline token, and consider a server-side concurrent-HistoryRead limiter. +All four HistoryRead arms call `.GetAwaiter().GetResult()` per node handle with `CancellationToken.None` (`OtOpcUaNodeManager.cs:1911, 2059, 2171, 2203`). Safe w.r.t. the node-manager `Lock` (the overrides run outside it — correctly documented), but bounded only by the gateway client's 30 s `CallTimeout`. A single request naming N historized nodes against a slow historian holds one SDK request thread up to N × 30 s against `MaxRequestThreadCount = 100` / `MaxQueuedRequestCount = 200` (`OpcUaApplicationHost.cs:329-331`) — a handful of misbehaving history clients can exhaust the pool and degrade all OPC UA services. +*Recommendation:* parallelize per-node reads within a batch (bounded), pass a per-request deadline token, add a server-side concurrent-HistoryRead limiter. **S4 — HIGH: Primary-gate "default-allow while role unknown" opens a dual-primary window on the data plane.** -`DriverHostActor.HandleRouteNodeWrite` / `HandleRouteNativeAlarmAck` / native-alarm emission all gate on `_localRole is Secondary or Detached` and deliberately default to *allow* until the first redundancy snapshot arrives (`DriverHostActor.cs:1018-1026, 1074-1083, 956-960; OnRedundancyStateChanged :1109-1114`). Combined with DPS delivery being at-most-once and heartbeat-repaired every 10 s, a freshly-booted or snapshot-missed **secondary services device writes and emits alerts as if primary** for up to the heartbeat interval — and indefinitely if the NodeId identity mismatch (S5) bites. The boot-window rationale (single-node deploys) is sound, but the same signal gates *shared-field-device writes*. -*Recommendation:* for the write/ack gates specifically, prefer default-*deny* once the cluster has >1 driver member (discoverable from cluster state), or require at least one received snapshot before servicing writes on multi-node clusters. +`DriverHostActor` gates native-alarm emission, `HandleRouteNodeWrite`, and `HandleRouteNativeAlarmAck` on `_localRole is Secondary or Detached` and deliberately defaults to *allow* until the first redundancy snapshot arrives (`DriverHostActor.cs:977, 1039, 1095; OnRedundancyStateChanged :1126`). Combined with at-most-once DPS delivery heartbeat-repaired every 10 s, a freshly-booted or snapshot-missed **secondary services device writes and emits alerts as if primary** for up to the heartbeat interval — and indefinitely if the NodeId identity mismatch (S5) bites. The boot-window rationale (single-node deploys) is sound, but the same signal gates shared-field-device writes. The now-proven SBR failover (S1) does not narrow this window — it is a delivery/boot-order gap, not a downing gap. +*Recommendation:* default-*deny* for the write/ack gates once the cluster has >1 driver member, or require one received snapshot before servicing writes on multi-node clusters. (Named in STATUS.md's "suggested next" — not started.) **S5 — MEDIUM: Redundancy NodeId identity is string-matched across three independently-derived sources with no diagnostic on mismatch.** -`OpcUaPublishActor` matches `n.NodeId == _localNode.Value` (`OpcUaPublishActor.cs:512`); the snapshot side derives `host:port` from the gossiped `Member.Address` (`RedundancyStateActor.cs:147-148`) while the local side derives it from configured `PublicHostname:Port` (`ClusterRoleInfo`). If `PublicHostname` ever diverges from the gossiped address (DNS vs IP, container advertised name), the node silently computes ServiceLevel 0 / keeps a stale role — the exact shape of the historical "silently inert delivery" bug, with no log distinguishing it from legitimate Detached. -*Recommendation:* log-once (Warning) when the local node is absent from a received snapshot; assert the identity equality in a startup self-check. +`OpcUaPublishActor` matches `n.NodeId == _localNode.Value` (`OpcUaPublishActor.cs:512`); the snapshot side derives `host:port` from the gossiped `Member.Address` (`RedundancyStateActor.cs:147-148`), the local side from configured `PublicHostname:Port`. Divergence (DNS vs IP, container advertised name) silently computes ServiceLevel 0 / stale role — the shape of the historical "silently inert delivery" bug, indistinguishable from legitimate Detached. +*Recommendation:* log-once (Warning) when the local node is absent from a received snapshot; startup identity self-check. **S6 — MEDIUM: A crashed-and-restarted `DriverInstanceActor` loses its message-delivered state (desired subscriptions) until the next deploy.** -No actor in Runtime overrides `SupervisorStrategy` (grep confirms), so an exception in a driver child triggers Akka's default one-for-one **unlimited, non-backoff restart**. Restart re-runs `PreStart` (driver re-init from the Props spec) but the desired-subscription set arrives post-spawn via `SetDesiredSubscriptions` messages stored in actor state (`DriverInstanceActor.cs:84, 181, 861`); a restart wipes it and `DriverHostActor` has no restart-detection to re-send (restarts don't fire `Terminated`). A persistently-throwing driver also hot-loops restarts with no backoff. -*Recommendation:* wrap driver children in `BackoffSupervisor` (restart with exponential backoff) and have the child request its subscription set from the parent in `PreStart`, or have the parent push it on a `PostRestart` signal. Add a supervision test (none exists — see U6). +Still zero `SupervisorStrategy` overrides in Runtime (grep re-confirmed at f6eaa267): a throwing driver child restarts one-for-one, unlimited, no backoff. Restart re-runs `PreStart` but the desired-subscription set arrives post-spawn via `SetDesiredSubscriptions` stored in actor state (`DriverInstanceActor.cs:84, 190-203, 912`); a restart wipes it and the parent has no restart-detection to re-send. A persistently-throwing driver hot-loops. Note the new invoker does *not* change this: retries happen inside a single message handler; a handler that ultimately throws still escalates to the default decider. +*Recommendation:* `BackoffSupervisor` for driver children + child-requests-state-on-restart (or parent push on `PostRestart`). Still no supervision test (U6). **S7 — MEDIUM: `DriverInstanceActor.PostStop` blocks the actor shutdown path on driver shutdown.** -`_driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult()` (`DriverInstanceActor.cs:950`) is the only synchronous block in the Runtime actors; with `CancellationToken.None` a hung protocol stack can stall stop/re-deploy of the whole child set. -*Recommendation:* bound it (`WaitAsync(timeout)`) and log-and-abandon on expiry. +`_driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult()` (`DriverInstanceActor.cs:1009`) remains the only synchronous block in the Runtime actors; a hung protocol stack stalls stop/re-deploy of the whole child set. Slightly more load-bearing now: #13 makes stop+respawn the path for every ResilienceConfig edit. +*Recommendation:* bound it (`WaitAsync(timeout)`), log-and-abandon on expiry. **S8 — MEDIUM: Inbound Part 9 commands and native acks are at-most-once; the client sees `Good` even if the command is lost.** -The alarm-command router is a fire-and-forget DPS `Publish` and the native-ack router a fire-and-forget `Tell`, both wrapped in catch-log-drop (`OtOpcUaServerHostedService.cs:139-194`); the node manager has already returned `Good` so the SDK applies local condition state regardless (`OtOpcUaNodeManager.cs:769-772`). A mediator hiccup or missing `DriverHostActor` silently strands the engine/upstream state. This is a documented, deliberate trade-off (non-blocking under `Lock`), but it is invisible in operation. -*Recommendation:* add a counter/metric for dropped routes (both routers currently only log at Warning) and consider an engine-side reconciliation sweep. +Unchanged (`OtOpcUaServerHostedService.cs:139-194`; `OtOpcUaNodeManager.cs:769-772`). Deliberate (non-blocking under `Lock`), but invisible in operation. +*Recommendation:* counter/metric for dropped routes; engine-side reconciliation sweep. **S9 — MEDIUM: Certificate lifecycle has no renewal or expiry monitoring.** -The application certificate is auto-created self-signed with SDK defaults (2048-bit, **12-month lifetime**) and only checked at boot (`OpcUaApplicationHost.cs:305-317`). Nothing monitors expiry; ~12 months after first deploy, Sign/SignAndEncrypt endpoints and UserName-token encryption fail on live servers with no advance warning. -*Recommendation:* startup + periodic expiry check with a metric/health-check and a documented rotation runbook (the AdminUI cert-actions work covers client certs; the *server* cert lifecycle is the gap). +Unchanged (`OpcUaApplicationHost.cs:305-317`): self-signed, 12-month lifetime, checked only at boot. ~12 months after first deploy, Sign/SignAndEncrypt endpoints and UserName-token encryption fail with no warning. +*Recommendation:* startup + periodic expiry check surfaced via health/metric; rotation runbook. -**S10 — LOW: SDK start failure is swallowed and the node keeps running with Null sinks.** -`StartAsync` catches, logs, and returns (`OtOpcUaServerHostedService.cs:110-129`) — a deliberate availability choice (AdminUI stays up), but the node then silently no-ops all OPC UA work and nothing surfaces the condition through `/health` or ServiceLevel (which is itself served by the dead server). -*Recommendation:* set a health-check flag consumed by `MapOtOpcUaHealth` so fleet status shows the degraded state. +**S10 — LOW: SDK start failure is swallowed and the node keeps running with Null sinks** (`OtOpcUaServerHostedService.cs:110-129`); nothing surfaces it through `/health`. -**S11 — LOW: `AuditWriterActor` buffer is unbounded between flushes and drops whole batches on DB outage** (`ControlPlane/Audit/AuditWriterActor.cs:35, 83, 113-116`). Explicitly best-effort by contract; flag only if compliance-grade audit is ever required. (Currently moot — see U3: the pipeline has no producers.) +**S11 — LOW: `AuditWriterActor` buffer is unbounded between flushes and drops whole batches on DB outage** (`ControlPlane/Audit/AuditWriterActor.cs:35, 83, 113-116`). Explicitly best-effort; currently moot (U3: no producers). -**Positive stability notes (worth preserving):** the node-manager locking discipline is exemplary — every mutation under `Lock`, every `Server.ReportEvent` deliberately outside it (`OtOpcUaNodeManager.cs:1101-1136, 1503-1504, 1595-1614`), the write-dispatch continuation forced off the SDK thread via `RunContinuationsAsynchronously` (`:884-897`), and the revert guarded by "still holds the optimistic value" (`ShouldRevert`, `:1048`). The Deferred-sink swap-to-Null on stop prevents post-stop writes hitting a disposed manager. `ConfigPublishCoordinator` has real crash-recovery (rebuilds expected-acks from DB on `PreStart`). `HandleServiceLevelChanged`'s first-publish-always correctly defeats the SDK's 255 default. `ActorNodeWriteGateway` bounds its Ask at 10 s with the inner driver Ask at 8 s and the backend call at 5 s — a correctly nested timeout ladder. +**S12 — MEDIUM (NEW): The write dispatch site hardcodes `isIdempotent: true`, bypassing the per-tag `WriteIdempotentAttribute` contract.** +`DriverInstanceActor.HandleWriteTagAttribute` routes through `_invoker.ExecuteWriteAsync(ResolveHost(msg.TagId), isIdempotent: true, …)` (`DriverInstanceActor.cs:597-601`). The `WriteIdempotentAttribute` (`Core.Abstractions/WriteIdempotentAttribute.cs`) — whose whole purpose is per-tag opt-in to write retries, and which `DriverCapability.Write`'s doc and `ExecuteWriteAsync`'s non-idempotent arm are built around — has **zero readers anywhere in the tree**. Today this is behavior-neutral (the Write tier default is `RetryCount = 0`, and the site comment says so), but the moment an operator sets a Write retry in a per-instance `ResilienceConfig` (#13 made exactly this reachable), retries apply to **every** write on that instance — including command-shaped tags (write-1-to-pulse, PLC auto-resets) where a duplicate write double-triggers. The hardcode silently converts a per-tag safety contract into an instance-wide one. +*Recommendation:* either resolve idempotence per tag (plumb the attribute/tag-config through `WriteTagAttribute`) before honoring a configured Write retry, or document loudly on the `ResilienceConfig` authoring surface that Write.RetryCount > 0 retries all writes on the instance. Until then the safest change is `isIdempotent: false` with per-tag opt-in later — that keeps the non-idempotent no-retry arm authoritative. + +**S13 — LOW (NEW): Respawn-on-ResilienceConfig-change has a narrow stale-pipeline re-cache race.** +`DriverCapabilityInvokerFactory.Create` invalidates the instance's cached pipelines (`DriverCapabilityInvokerFactory.cs:71`) before the *old* child has terminated (`StopChild` is an async `Context.Stop`, `DriverHostActor.cs:1278-1280, 1658-1664`). An old-child handler already dequeued and mid-await can reach its invoker call *after* the invalidate; `GetOrCreate` then re-caches a pipeline built from the old child's captured options snapshot under the same `(instance, host, capability)` key — and the cache ignores options on a hit (`DriverResiliencePipelineBuilder.cs:75-79`), so the new child silently serves the stale pipeline until the next respawn. Window is milliseconds (the mailbox suspends on Stop; a pipeline is resolved once per `ExecuteAsync` at call start), but the failure is the exact "options-blind cache hit" the invalidate was added to prevent. +*Recommendation:* include an options fingerprint in `PipelineKey` (removes the invalidate-ordering dependency entirely), or invalidate from the *new* invoker lazily on first use after the old child's `Terminated`. + +**Positive stability notes (worth preserving):** the node-manager locking discipline remains exemplary (every mutation under `Lock`, every `ReportEvent` outside it, write-dispatch continuation forced off the SDK thread, revert guarded by `ShouldRevert`). The new resilience wiring is carefully done: exception transparency holds (pipeline exceptions — `TimeoutRejectedException`, `BrokenCircuitException` — surface through the same `catch (Exception)` arms the raw calls used, so failure paths like `WriteAttributeResult(false, …)` and Reconnecting transitions are unchanged); retry `ShouldHandle` excludes `OperationCanceledException`, so the actor's outer `cts` deadlines (5 s write/ack/unsub, 10 s subscribe, 30 s discover) still hard-bound mailbox suspension even with retries enabled; the Discover site's ConfigureAwait analysis (invoker-internal `ConfigureAwait(false)` does not leak to the caller's actor-context resume) is correct and covered by a real-`CapabilityInvoker` actor-context test; pipeline order Timeout→Retry→Breaker means the tier timeout bounds the *total* including retries. The `TwoNodeClusterHarness.HardKillNodeAAsync` design (transport shutdown as crash sim, stable-streak takeover polling, diagnostic timeout message naming the inactive-resolver signature) is a model integration test. ### 2. Performance **P1 — HIGH: Any structural deploy change triggers a full address-space teardown/rebuild that severs every client's monitored items server-wide.** -`AddressSpaceApplier.Apply` forces `RebuildAddressSpace()` for *any* added/removed equipment, alarm, tag, or virtual tag (`AddressSpaceApplier.cs:134-154`); the rebuild removes every variable/folder/condition under one `Lock` hold and re-materialises from scratch (`OtOpcUaNodeManager.cs:1690-1736`). Recreated nodes are new `NodeState` instances, so existing client subscriptions on the old nodes go dead — the F10b surgical path (`UpdateTagAttributes`/`UpdateFolderDisplayName`) exists precisely because of this, but covers only attribute edits and renames. Adding one tag to one equipment still drops every subscription on a production SCADA server. -*Recommendation:* extend the surgical path to pure-add deltas first (`EnsureFolder`/`EnsureVariable` are already idempotent — adds need no teardown; `RaiseNodesAddedModelChange` already exists for announcement), then scope removals per-equipment. This is the highest-leverage performance/stability item in the subsystem. +Unchanged (`AddressSpaceApplier.cs:134-154`; `OtOpcUaNodeManager.cs:1690-1736`): any added/removed equipment, alarm, tag, or virtual tag forces `RebuildAddressSpace()`; recreated nodes are new `NodeState` instances, so existing subscriptions go dead. The F10b surgical path covers only attribute edits and renames. Adding one tag still drops every subscription on a production SCADA server. **The declared prerequisite (U2 reflection forwarding guard) is now merged — the surgical pure-adds work is unblocked but not started.** +*Recommendation:* extend the surgical path to pure-add deltas first (`EnsureFolder`/`EnsureVariable` are already idempotent; `RaiseNodesAddedModelChange` exists), then scope removals per-equipment. Still the highest-leverage item in the subsystem. **P2 — MEDIUM: The value hot path is one global lock acquisition + one actor message per value, with no batching.** -Per published value: driver child `Tell` → `DriverHostActor.ForwardToMux` (dictionary lookup + up to two `Tell`s, `DriverHostActor.cs:561-589`) → `OpcUaPublishActor` (pinned thread) → `sink.WriteValue`, which takes the **global node-manager `Lock`** per value (`OtOpcUaNodeManager.cs:266-281`), contending with SDK read/subscription/publish threads. The mux itself is lean (`DependencyMuxActor.cs:95-108` — early-drop for uninterested refs, set-based fan-out). At high tag counts × fast poll intervals this serialises everything through one lock and allocates one record per hop. -*Recommendation:* add a batched `WriteValues(IReadOnlyList<…>)` sink call (one lock hold per driver publish cycle) — the seam (`IOpcUaAddressSpaceSink`) makes this a contained change, but remember the Deferred-wrapper forwarding trap (U2). +Unchanged: driver child `Tell` → `ForwardToMux` → `OpcUaPublishActor` → `sink.WriteValue` taking the global node-manager `Lock` per value (`OtOpcUaNodeManager.cs:266-281`). Same unblocked-by-U2, not-started status as P1. +*Recommendation:* batched `WriteValues(IReadOnlyList<…>)` sink call — the reflection guard now auto-covers the new member's forwarding. -**P3 — MEDIUM: HistoryRead-Events has no paging and translates `NumValuesPerNode == 0` to `int.MaxValue`.** -`EventMaxEvents` deliberately maps "no limit" to unbounded (`OtOpcUaNodeManager.cs:1944-1954`), and the Events arm never issues continuation points ("full window in one shot", `:1914-1921`). A wide time window over a busy alarm source materialises the entire result in memory on both gateway and server. -*Recommendation:* impose a server-side max (mirroring `MaxTieClusterOverfetch`'s philosophy) and fail loudly or page beyond it. +**P3 — MEDIUM: HistoryRead-Events has no paging and translates `NumValuesPerNode == 0` to `int.MaxValue`** (`OtOpcUaNodeManager.cs:1944-1954`; no continuation points `:1914-1921`). Unchanged. -**P4 — LOW: Every deploy re-runs the four `Materialise*` passes over the full composition** (`OpcUaPublishActor.cs:338-354`) — acceptable because `EnsureFolder`/`EnsureVariable` early-return on existing ids, but each pass still takes/releases the `Lock` per node; fold into P2's batching if it shows up in profiles. On the positive side, the Raw-paging tie-cluster over-fetch is explicitly bounded (`MaxTieClusterOverfetch`, default 65 536) with a loud-fail backstop, and `HandleDiscoveredNodes` has an unchanged-plan short-circuit preventing ~15× re-subscribe blips during the discovery window (`DriverHostActor.cs:658-673`) — both good designs. +**P4 — LOW: Every deploy re-runs the four `Materialise*` passes over the full composition** (`OpcUaPublishActor.cs:338-354`) — acceptable (early-return on existing ids); fold into P2 if it profiles hot. Positive: Raw-paging tie-cluster over-fetch stays bounded; `HandleDiscoveredNodes` unchanged-plan short-circuit preserved. New-code note: the resilience pipeline adds one `GetOrCreate` dictionary hit + options-snapshot closure per capability call on *dispatch* paths only (write/ack/subscribe/discover) — the per-value publish hot path is untouched by #10, which is the right call. ### 3. Conventions **C1 — MEDIUM: The `ServerHistorian` section is bound imperatively in five places with no `IOptions` registration.** -`Program.cs:120-122` (own bind + `Validate()` warnings), `AddServerHistorian`, `AddAlarmHistorian`, `AddHistorianProvisioning` (each re-`Get<>`s the section, `Runtime/ServiceCollectionExtensions.cs:86, 132, 168`), and `OtOpcUaServerHostedService`'s constructor (`.cs:88-90`, self-justified as "self-contained"). Warnings can log twice; five bind sites will drift. -*Recommendation:* one `AddValidatedOptions` and inject `IOptions<>` everywhere, matching the `OpcUa`/`Ldap` pattern that already exists (`Program.cs:102, 254-255`). +Unchanged: `Program.cs:121`, `AddServerHistorian`/`AddAlarmHistorian`/`AddHistorianProvisioning` (`Runtime/ServiceCollectionExtensions.cs:132, 168` et al.), `OtOpcUaServerHostedService.cs:89`. +*Recommendation:* one `AddValidatedOptions` + `IOptions<>` everywhere, matching the existing `OpcUa`/`Ldap` pattern. **C2 — MEDIUM: Options validation is two-tier and the tiers disagree.** -`OpcUa` and `Security:Ldap` get real fail-fast `ValidateOnStart` validators (`OpcUaApplicationHostOptionsValidator` requires ≥1 security profile; `LdapOptionsValidator` refuses plaintext-without-opt-in) — good. `ServerHistorian` / `ContinuousHistorization` / `AlarmHistorian` only get advisory `Validate()` warning lists, and `DevStubMode=true` (accept-any-credentials Administrator grant) is merely log-warned, never blocked, in production (`OtOpcUaLdapAuthService.cs:79-88`). -*Recommendation:* promote the historian sections to the validator pattern; add an environment-gated guard (or at minimum a startup validator failure) for `DevStubMode` outside Development. +Unchanged: `OpcUa`/`Security:Ldap` get fail-fast `ValidateOnStart` validators; `ServerHistorian`/`ContinuousHistorization`/`AlarmHistorian` get advisory warning lists; `DevStubMode=true` is merely log-warned in production (`OtOpcUaLdapAuthService.cs:79-88`). +*Recommendation:* promote the historian sections to the validator pattern; environment-gate `DevStubMode`. -**C3 — LOW: Library code logs via the static Serilog logger** (`Runtime/ServiceCollectionExtensions.cs:90, 100, 136`) while everything else uses MEL `ILogger` — works only because `Program.cs:305` assigns `Log.Logger`, an ordering-sensitive hidden coupling. The node manager similarly logs through the obsolete `Utils.LogError` static trace with six `#pragma warning disable CS0618` sites (`OtOpcUaNodeManager.cs:449-451` et al.) — the acknowledged follow-up (wire an `ITelemetryContext`) should actually land. +**C3 — LOW: Library code logs via the static Serilog logger** (`Runtime/ServiceCollectionExtensions.cs:90, 100, 136`) and the node manager keeps six `#pragma warning disable CS0618` `Utils.LogError` sites. Unchanged — though notably the *new* resilience registrations do it right (`ILoggerFactory`-created categorized loggers, `DriverFactoryBootstrap.cs:58-70`), so the island isn't growing. -**C4 — LOW: `WithOtOpcUaRuntimeActors` is service-locator style with silent Null fallbacks** (`Runtime/ServiceCollectionExtensions.cs:211-237`) — the accepted Akka.Hosting idiom, and the missing-registration warnings (evaluator, recorder deps) are good; but `resolver.GetService()` (`:298`) has *no* fallback warning parallel to the others, despite being the exact seam that shipped dormant once (PR #423). The `dispatched=N, requested=0` tally in the applier (`AddressSpaceApplier.cs:274-282`) partially compensates. +**C4 — LOW: `WithOtOpcUaRuntimeActors` service-locator fallbacks are inconsistently warned.** `resolver.GetService()` (`Runtime/ServiceCollectionExtensions.cs:303`) still has no missing-registration warning despite being the seam that shipped dormant once. The new `IDriverCapabilityInvokerFactory` resolve (`:219-220`) is likewise silent-fallback, but is the better-defended instance of the pattern: the fallback is *deliberate* for admin-only nodes, and `ResilienceInvokerFactoryRegistrationTests` pins the driver-node bind to the real factory — the guard the provisioning seam still lacks. -**Positive conventions notes:** the layering is clean and deliberate — `OpcUaServer` is Akka-free (delegates/gateway seams), `Runtime` is SDK-free (sink seams), Core holds the pure calculators (`ServiceLevelCalculator`, planners); the Null-object + Deferred late-binding pattern is applied consistently (sink, ServiceLevel, write gateway, historian source, provisioning, history writer); actor `Props` factories + registry marker keys are uniform; scope-correctness around the scoped `IGroupRoleMapper` inside the singleton authenticator is explicitly handled (`LdapOpcUaUserAuthenticator.cs:63-67`); NodeId is canonically `host:port` everywhere except one dormant spot (U4). +**Positive conventions notes:** the layering discipline held under pressure — #10's filed plan (thread `CapabilityInvoker` directly into Runtime) would have broken the Polly-free boundary, and the shipped seam (`IDriverCapabilityInvoker`/`IDriverCapabilityInvokerFactory` in Core.Abstractions, mirroring `IDriverFactory`, Null-object defaults, DI-resolved with pass-through fallback) is exactly this codebase's established pattern; the Runtime csproj still references only `Core.Abstractions`. The corrected S1 docs are a conventions win too: `BuildClusterOptions`'s XML comment and `akka.conf` now state precisely *why* the typed option exists, what activates SBR, the keep-oldest-for-2-nodes rationale, and the HOCON/typed-option consistency constraint. Layer inventory unchanged otherwise: Akka-free `OpcUaServer`, SDK-free `Runtime`, pure calculators in Core, uniform Props factories + registry keys, scoped-mapper handling in the singleton authenticator. ### 4. Underdeveloped areas -**U1 — HIGH (documentation drift on a load-bearing claim): CLAUDE.md "KNOWN LIMITATION 2" is stale — the ContinuousHistorizationRecorder ref-feed gap is CLOSED in code.** -The recorder is still *spawned* with `historizedRefs: Array.Empty()` (`Runtime/ServiceCollectionExtensions.cs:272`), but the applier now feeds it the add/remove delta on every deploy via `ActorHistorizedTagSubscriptionSink` → `UpdateHistorizedRefs` (`Runtime/ServiceCollectionExtensions.cs:287-292`; `AddressSpaceApplier.cs:210-213, 310-353`; `ContinuousHistorizationRecorder.cs:67-78`). Restart convergence works because `OpcUaPublishActor._lastApplied` is in-memory, so the first post-boot rebuild diffs against empty and emits the full set as Added (`OpcUaPublishActor.cs:326-336`). CLAUDE.md still says "the recorder registers interest in nothing and historizes nothing… Until that feed lands, continuous historization records no values." -*Recommendation:* update CLAUDE.md; add an explicit restart-convergence test (boot → apply full plan → assert mux interest registered), since the chain (empty seed + delta feed + in-memory `_lastApplied`) is load-bearing and only implicitly covered today. +**U3 — MEDIUM: The structured audit pipeline is fully built, tested, and has zero producers.** Unchanged (`ControlPlane/Audit/AuditOutcomeMapper.cs:12-18`; `Security/Audit/AuditActor.cs:18-24`). Wire producers or delete. -**U2 — MEDIUM: The `DeferredAddressSpaceSink` capability-forwarding trap is currently closed but only half test-guarded.** -Audit result: **all** current members forward correctly, including both `ISurgicalAddressSpaceSink` methods with capability-sniffing fallback (`src/Core/…/DeferredAddressSpaceSink.cs:58-70`) — no inert capability today. But tests assert forwarding for only ~5 of the 10 members; `WriteAlarmCondition`, `MaterialiseAlarmCondition`, `EnsureFolder`, `RebuildAddressSpace`, `RaiseNodesAddedModelChange` have no per-member forwarding assertion (`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` / `DeferredAddressSpaceSinkTests.cs`). The F10b incident (surgical path dead in prod for exactly this reason) proves the failure mode is real. -*Recommendation:* add a reflection-driven exhaustive test: every method of `IOpcUaAddressSpaceSink` + every optional capability interface the wrapper claims must reach a recording inner sink. Same treatment for `DeferredServiceLevelPublisher`. +**U4 — MEDIUM: `FleetStatusBroadcaster.DriverHostStatusHeartbeat` is dead code with a latent NodeId-mismatch repeat.** Unchanged (`FleetStatusBroadcaster.cs:38, 110-121`; host-only key `:152-159` vs `host:port` everywhere else). -**U3 — MEDIUM: The structured audit pipeline is fully built, tested, and has zero producers.** `AuditWriterActor` + `AuditOutcomeMapper` state "no live structured AuditEvent emit sites; all production audit flows through the bespoke stored-procedure path" (`ControlPlane/Audit/AuditOutcomeMapper.cs:12-18`; `Security/Audit/AuditActor.cs:18-24`). Either wire producers or delete — dormant tested code decays. +**U5 — MEDIUM: Native Part 9 conditions support Acknowledge-to-driver only.** Unchanged (`OtOpcUaNodeManager.cs:647-658`; Enable/Disable `BadNotSupported` `:698-703`). Shelving a Galaxy alarm from a Part 9 client remains silently ineffective upstream. -**U4 — MEDIUM: `FleetStatusBroadcaster.DriverHostStatusHeartbeat` is dead code with a latent NodeId-mismatch repeat.** No producer anywhere sends the heartbeat (`FleetStatusBroadcaster.cs:38, 110-121`), so `CurrentRevision` is always null and the freshness branch never fires; and the broadcaster keys nodes by **host only** (`:152-159`) while every other subsystem uses `host:port` — if the heartbeat is ever wired with a `host:port` NodeId it will create phantom node records, re-enacting the historical NodeId bug. -*Recommendation:* wire the producer (DriverHostActor already knows its applied revision) or remove; either way canonicalise the key to `host:port`. +**U6 — MEDIUM: Test-coverage gaps still concentrate on the fragile seams — though the two worst are now closed.** +Closed since 9cad9ed0: **hard-kill failover** (`HardKillFailoverTests`, in-process 2-node, CI-runnable — the single biggest prior blind spot) and **green-because-skipped** (whole-solution CI leg + `assert-not-all-skipped.sh` fail-on-skip gate). Still open, in priority order: +- **DPS delivery of redundancy/ServiceLevel state is untested** — `RedundancyStateActorTests` stub the broadcast; `ServiceLevelEndToEndTests` inject state directly; the harness (even hard-kill) asserts cluster membership, not that `RedundancyStateChanged` *arrives* and flips ServiceLevel/gates on the survivor. The known "unit tests can't catch it" blind spot survives the failover test. +- **No actor supervision/restart test** anywhere (S6). +- **Outbox durability across process restart** and recorder **restart-convergence** (the U1 residual) untested. +- `OpcUaServer.IntegrationTests` still contains **one test** (`DualEndpointTests`) — no over-the-wire security-mode matrix, subscription, or HistoryRead round-trip. +- **Resilience wiring behavioral coverage is 2 of 6 sites** (`DriverInstanceActorResilienceWiringTests`: Subscribe + Write-with-resolved-host). The analyzer proves the other four *are wrapped* but not that they pass the right `DriverCapability`/host key — a transposed enum member at the ack or alarm-subscribe site would compile, pass the analyzer, and ship. -**U5 — MEDIUM: Native Part 9 conditions support Acknowledge-to-driver only.** Confirm/AddComment/Shelve on a *native* condition still route to the scripted engine, which does not own them (documented H6c scope, `OtOpcUaNodeManager.cs:647-658`); Enable/Disable short-circuits to `BadNotSupported` (`:698-703`). Operators using a Part 9 client will find shelving a Galaxy alarm silently ineffective upstream. +**U7 — LOW:** `IHistoryWriter` permanently Null-wired; H2 HistoryUpdate unimplemented (`OtOpcUaNodeManager.cs:1797-1801`); `LdapAuthFailure` lacks `DirectoryUnavailable`. Tracked backlog, unchanged. -**U6 — MEDIUM: Test-coverage gaps concentrate exactly where the architecture is most fragile** (from the tests/Server sweep, ~910 tests total across 6 projects): -- **DPS delivery of redundancy/ServiceLevel state is untested** — `RedundancyStateActorTests` stub the broadcast, `ServiceLevelEndToEndTests` inject `NodeRedundancyState` directly, and the `TwoNodeClusterHarness` runs in-process with EF InMemory. This is the known "unit tests can't catch it" blind spot, and it isn't even documented in a test comment. -- **No actor supervision/restart test** anywhere (relates to S6) — only "init failure keeps Reconnecting state" is covered. -- **Outbox durability across process restart** and sustained-outage backpressure untested (a single failure→retry→ack cycle is). -- `OpcUaServer.IntegrationTests` contains **one test** (dual-endpoint ServerArray) — no over-the-wire security-mode matrix, subscription, or HistoryRead round-trip. -- Docker-fixture-dependent driver-protocol tests self-skip when `10.100.0.35` is unreachable — green local/macOS runs can hide protocol regressions. -Strengths worth noting: node-manager HistoryRead/paging (~60 tests), the write-gate/revert path, LDAP fail-closed edges, and Composer/Applier (~120 tests) are genuinely well covered. +**U8 — LOW: `BuildSecurityPolicies` doc-comment claims the empty-profile fallback is "logged and very visible" but the method logs nothing** (`OpcUaApplicationHost.cs:376-418`). Unchanged. -**U7 — LOW: `IHistoryWriter` is permanently Null-wired** (vtag historization, infra-gated on a nonexistent live-write RPC — `Runtime/ServiceCollectionExtensions.cs:56-58`); **H2 HistoryUpdate** remains unimplemented (HistoryRead-only server; `IsReadModified` rejected, `OtOpcUaNodeManager.cs:1797-1801`) — both tracked backlog, listed for completeness. `LdapAuthFailure` lacks a `DirectoryUnavailable` value (directory-down is overloaded onto `ServiceAccountBindFailed`). +**U9 — LOW: `EnsureVariable` silently ignores changed historize-intent on an existing node** (`OtOpcUaNodeManager.cs:1345-1349`) — invariant still lives in two files' comments rather than an assert. -**U8 — LOW: `BuildSecurityPolicies` doc-comment claims the empty-profile fallback is "logged and very visible" but the method logs nothing** (`OpcUaApplicationHost.cs:376-418`). Defused in production by the options validator (`MinCount ≥ 1`, `OpcUaApplicationHostOptionsValidator.cs`), but the silent fail-open-to-None remains for direct embedders/tests; fix the comment or add the log. - -**U9 — LOW: `EnsureVariable` silently ignores changed historize-intent on an existing node** (documented, `OtOpcUaNodeManager.cs:1345-1349`) — correct today because the planner routes such deltas to `UpdateTagAttributes` or a rebuild, but the invariant lives in two files' comments rather than an assert. +**U10 — LOW (NEW): The `DriverResilienceStatusTracker` has no reader — pipeline observability is log-only.** +The tracker is registered, fed by every retry/breaker event, and consumed by nothing (Admin `/hosts` Stream E.2/E.3 never shipped). The interim mitigation (retry/breaker-open/breaker-close logging in `DriverResiliencePipelineBuilder.cs:124-170`, added with #10) is real and correctly gated, but breaker state — now a production behavior that can reject calls (`BrokenCircuitException`) — is invisible to operators except in logs. Known residual from FOLLOWUP-10; recorded here so the review tracks it. +*Recommendation:* ship the `/hosts` resilience column, or at minimum a metric per breaker-open. --- ## Maturity ratings -| Dimension | Rating (1-5) | Justification | -|---|---|---| -| Stability | **3** | Excellent node-manager locking/self-correction discipline and Null/Deferred boot safety, but the inert SBR (S1) breaks hard-crash failover cluster-wide, and LDAP/HistoryRead thread-blocking (S2/S3) plus the primary-gate boot window (S4) are real production exposures. | -| Performance | **3** | Hot paths are lean per-message and history paging is carefully bounded, but the rebuild-on-any-structural-change subscription wipe (P1) and per-value global-lock writes (P2) will not survive large tag counts or frequent deploys gracefully. | -| Conventions | **4** | Deliberate, consistent layering (Akka-free OpcUaServer, SDK-free Runtime, Null/Deferred seams, uniform actor patterns); loses a point for the five-way `ServerHistorian` binding, two-tier options validation, and static-logger islands. | -| Underdeveloped | **3** | Core capabilities are complete and the once-dormant provisioning/ref-feed seams are now wired, but stale load-bearing docs (U1), a half-guarded forwarding trap (U2), dormant audit/heartbeat code (U3/U4), and test blind spots exactly on the fragile seams (U6) leave meaningful debt. | +| Dimension | 2026-07-08 | 2026-07-12 | Justification | +|---|---|---|---| +| Stability | **3** | **3** | The prior Critical dissolved (S1's premise was wrong — SBR was always active — and hard-kill failover is now integration-proven), which removes the cluster-wide failure mode from the ledger. But the three untouched Highs (S2 LDAP thread-stall, S3 HistoryRead pool exhaustion, S4 dual-primary boot window) are exactly the realistic production exposures the band describes, and the new S12 idempotence bypass adds a loaded footgun. Better-evidenced, not yet safer where it counts. | +| Performance | **3** | **3** | Nothing on the hot paths changed. P1 (rebuild severs all subscriptions) and P2 (per-value global lock) remain; their prerequisite guard merged, so the score is now held down purely by unstarted work. The resilience pipeline correctly stayed off the per-value publish path. | +| Conventions | **4** | **4** | The new code is the pattern at its best (Polly-free seam mirroring `IDriverFactory`, Null-object + DI-guard, corrected load-bearing docs), and the static-logger island stopped growing — but C1/C2/C3/C4 themselves are all still open, so the score holds rather than rises. | +| Underdeveloped | **3** | **4** | The two systemic traps are retired with durable guards (U2 reflection forwarding test closes the F10b/PR#423 class; fail-on-skip CI closes green-because-skipped), U1's doc drift is fixed, the once-dormant CapabilityInvoker is wired end-to-end with analyzer + DI + behavioral guards, and the worst coverage blind spot (hard-kill failover) is tested. Remaining: dormant audit/heartbeat code (U3/U4), the DPS-delivery and supervision test gaps, and the reader-less tracker (U10). | --- ## Cross-cutting themes -1. **Config-in-HOCON is not config-in-effect** — the SBR block shows Akka behavior declared in `akka.conf` can be silently inert without the activating registration; audit other HOCON-declared behaviors (dispatcher blocks, singleton settings) the same way. -2. **The Deferred/Null seam pattern is powerful but trap-prone**: every new optional capability must be forwarded through the wrappers *and* exhaustively test-guarded, or it ships inert (proven twice: F10b surgical sink, PR #423 provisioning). A reflection-based forwarding test would retire the trap class. -3. **"Default-allow until told otherwise" gates** (primary write gate, peer-probe benefit-of-the-doubt, ServiceLevel legacy seam) consistently trade split-brain safety for boot-window availability; each is individually reasoned but they compound with at-most-once DPS delivery. -4. **Unit-test greenness systematically overstates distributed correctness** here: DPS delivery, supervision restarts, hard-kill failover, and Docker-gated protocol paths all sit outside the test net — and the project's own history (redundancy-delivery bug, F10b inertness) shows these are exactly where the bugs live. +1. **The review's own premises need negative controls too.** S1 was filed as Critical on a correct-looking static read (no `downing-provider-class` anywhere) that missed a framework default; only the #9 negative control (force `downing-provider-class = ""`) exposed it. The corrected framing is now pinned in code comments, `akka.conf`, and a test that guards the *outcome* regardless of activation path — the right durable form. Treat "config X is inert" findings as unproven until a negative control flips the observed behavior. +2. **The Deferred/Null seam pattern's trap class is now guarded, not just known.** The reflection-exhaustive forwarding test (U2) auto-covers future sink/publisher members via `DispatchProxy` plus a guard-of-the-guard on the interface set — new capabilities (P1 surgical adds, P2 `WriteValues`) can no longer ship inert through the wrappers. The same "built-but-never-wired" failure mode got a second guard at the DI layer (`ResilienceInvokerFactoryRegistrationTests`). The remaining unguarded instance of the pattern is the silent `IHistorianProvisioning` fallback (C4). +3. **"Default-allow until told otherwise" gates remain the compounding risk.** S4 (primary write gate) + at-most-once DPS delivery + the untested delivery path (U6) still stack; the SBR work hardened membership convergence but none of the delivery-dependent gates. S12 joins this family: `isIdempotent: true` is a default-permissive stance on a safety contract, dormant only because a *different* default (RetryCount=0) currently masks it. +4. **Unit-test greenness overstating distributed correctness is being paid down in the right order** — hard-kill failover (done), fail-on-skip (done), DPS delivery / supervision restarts / outbox durability (still open, now the top of the list). The pattern to keep: every guard shipped with a verified negative control. diff --git a/archreview/04-adminui.md b/archreview/04-adminui.md index fb4a6493..a94e689a 100644 --- a/archreview/04-adminui.md +++ b/archreview/04-adminui.md @@ -1,11 +1,44 @@ # Architecture Review 04 — AdminUI (Blazor Server) -- **Date:** 2026-07-08 -- **Commit:** `9cad9ed0` +- **Date:** 2026-07-12 +- **Commit:** `f6eaa267` (master, clean tree) +- **Updates:** the 2026-07-08 review at `9cad9ed0`. **Zero AdminUI source files changed between the two commits** (`git diff 9cad9ed0..f6eaa267 -- src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` is empty; the ~40 intervening commits are the arch-review remediation merges in Cluster/Runtime/Core/Drivers, a fixdocs XML-doc sweep, and a HistorianGateway package bump). Every prior finding was re-verified against the code rather than carried over on trust; all line references below re-checked at `f6eaa267`. - **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` (77 razor components, ScriptAnalysis backend, minimal APIs, SignalR bridges, UNS service layer), plus `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` for coverage assessment. Auth *policy definitions* live in `ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` and are referenced where the AdminUI consumes them. --- +## Prior-finding status (9cad9ed0 → f6eaa267) + +| ID | One-liner | Status @ f6eaa267 | +|---|---|---| +| S-1 (Med) | Three components don't drain in-flight timer callbacks on dispose | **STILL OPEN** — `Fleet.razor:171` and `AlarmsHistorian.razor:90` are still `public void Dispose() => _timer?.Dispose();`; `DriverTestConnectButton.razor:68–81` still uses `System.Timers.Timer` with an async `Elapsed` lambda + sync dispose | +| S-2 (Med) | `Fleet.LoadAsync` has no catch; `AlarmsHistorian` bare `catch { }` leaves "Loading…" forever | **STILL OPEN** — `Fleet.razor:120–159` is `try/finally` with no catch; `AlarmsHistorian.razor:73–78` still admits the role-gating message is a TODO | +| S-3 (Med) | No `` anywhere | **STILL OPEN** — `grep -rn ErrorBoundary` over the project returns nothing | +| S-4 (Med) | Delete flows re-fetch RowVersion at click time, defeating the concurrency guard | **STILL OPEN** — `GlobalUns.razor:279–326` (Area/Line/Equipment branches) and `EquipmentPage.razor:370–377/415–422/460–467` still load fresh + pass the fresh RowVersion ("Load the tag fresh…" comments intact) | +| S-5 (Med) | No confirmation on `EquipmentPage`/`ScriptEdit` deletes | **STILL OPEN** — `EquipmentPage.razor:172/217/263` single-click delete buttons; `ScriptEdit.razor:68→140` `DeleteAsync` with no confirm | +| S-6 (Low) | `DriverStatusPanel` result-chip timer lacks the stale-fire token guard `Alerts` has | **STILL OPEN** — `Alerts.razor:244–257` has the token compare; `DriverStatusPanel.razor:293–299` clears unconditionally | +| S-7 (Low) | `AdminOperationsClient.AskAsync` bypasses the 10 s ask timeout | **STILL OPEN** — `Clients/AdminOperationsClient.cs:58–59` forwards only the caller's token; the three typed methods still double-guard | +| P-1 (Med) | Monaco pushes the full document to .NET on every keystroke, undebounced | **STILL OPEN** — `wwwroot/js/monaco-init.js:205–208` | +| P-2 (Med) | ScriptAnalysis recompiles from scratch per request, six endpoints deep | **STILL OPEN** — `ScriptAnalysis/ScriptAnalysisService.cs:60–67`, no `(text → compilation)` memoization | +| P-3 (Low) | Per-circuit DB polling on dashboard pages | **STILL OPEN** — `Hosts.razor:229–245` (5 s), `Fleet.razor:112` (10 s) | +| P-4 (Low) | `Deployments` runs a full config snapshot + hash per page load for the drift badge | **STILL OPEN** — `Deployments.razor:98` `SnapshotAndFlattenAsync` | +| P-5 (Low) | `Alerts` rows lack `@key` while prepending; no `` (bounded by design) | **STILL OPEN** — `Alerts.razor:63–70` `` without `@key` | +| P-6 (Low) | `inlay-hints` round-trips for a documented no-op | **STILL OPEN** — `monaco-init.js:99–117` provider active; `ScriptAnalysisService.cs:415` returns empty | +| C-1 (High) | Three authorization idioms; the largest mutating surface is bare `[Authorize]` | **STILL OPEN** — re-enumerated every page attribute: only `Deployments`/`Scripts`/`ScriptEdit` carry `Roles="Administrator,Designer"`, only `RoleGrants` carries `Policy="FleetAdmin"`; `GlobalUns`, `EquipmentPage`, all `Clusters/*` editors, all 8 driver pages, and `Reservations` remain bare `[Authorize]`. No remediation branch targeted this (STATUS.md action-list item 5, unscheduled) | +| C-2 (Med) | CLAUDE.md said ScriptAnalysis was `FleetAdmin`-gated; code uses `Roles="Administrator,Designer"` | **FIXED** — docs branch `9fadead6` (merged to master via `b67bd9e8`) corrected CLAUDE.md, which now reads "gated to the `Administrator,Designer` roles via `RequireAuthorization`" (CLAUDE.md:223); code unchanged and still correct | +| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | **STILL HOLDS** — map/validator/editors unchanged | +| C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | **STILL HOLDS** — pages + `*FormSerializationTests` unchanged | +| C-5 (Med) | Two data-access idioms: service seam vs EF-in-page | **STILL OPEN** — `ScriptEdit.razor:87/109/132/146/151`, `Scripts.razor:60`, `Deployments.razor:83`, `Fleet.razor:125`, `Hosts.razor:273` still run EF directly in `@code` | +| C-6 (Low) | Naming/marker nits (`"GalaxyMxGateway"` literal, `DataTypes` duplication, casing drift) | **STILL OPEN** — `TagModal.razor:98/262/275` etc. unchanged | +| U-1 | Strong C# seam tests, zero component rendering coverage | **STILL OPEN** — still exactly 443 `[Fact]`/`[Theory]` cases, still no bUnit (documented repo posture) | +| U-2 | Stub/known-gap behaviors (InlayHints no-op, AlarmsHistorian TODO, `MarkersChanged` untyped, GlobalUns not-implemented message) | **STILL OPEN** — all four verified in place | +| U-3 | Raw-JSON fallback textarea unvalidated for well-formed JSON | **STILL OPEN** — `TagModal.razor:130–136` unchanged | +| U-4 | Accessibility gaps (tree expander aria, modal focus trap, live regions) | **STILL OPEN** — unchanged | + +New findings this pass: **C-7** (ResilienceFormModel lossy round-trip) and **U-5** (resilience runtime status has no AdminUI reader) — both on the driver resilience-override surface, which existed at `9cad9ed0` but was not covered by the prior review and became *load-bearing* since: the remediation merges (#10 `bacea1a4`, #13 `75403caa`) wired `CapabilityInvoker` + per-instance `ResilienceConfig` into production dispatch, so what this form emits now changes runtime retry/breaker behavior. + +--- + ## Architecture Overview ### Packaging and mounting @@ -18,15 +51,15 @@ The AdminUI is a **Razor Class Library**, not an app: the fused Host calls `AddA |---|---| | Dashboards | `Home`, `Fleet` (per-node deploy state, 10 s poll), `Hosts` (Akka topology + driver health, 5 s poll + event push), `AlarmsHistorian` (sink status, 5 s poll) | | Live tails | `Alerts` (alarm transitions + DriverOperator Ack/Shelve), `ScriptLog` | -| Config: clusters | `ClustersList`, `ClusterEdit/Overview/Namespaces/Acls/Audit/Redundancy/Drivers`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, 8 driver pages behind `DriverEditRouter` + `DriverTypePicker` | -| Config: UNS | `GlobalUns` (tree + Area/Line modals + CSV import), `EquipmentPage` (tabbed Details/Tags/VirtualTags/Alarms with `TagModal`/`VirtualTagModal`/`ScriptedAlarmModal`) | +| Config: clusters | `ClustersList`, `ClusterEdit/Overview/Namespaces/Acls/Audit/Redundancy/Drivers`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, 8 driver pages behind `DriverEditRouter` + `DriverTypePicker` (each embedding `DriverResilienceSection` for per-instance resilience overrides) | +| Config: UNS | `GlobalUns` (tree + Area/Line modals + paste-CSV import), `EquipmentPage` (tabbed Details/Tags/VirtualTags/Alarms with `TagModal`/`VirtualTagModal`/`ScriptedAlarmModal`) | | Scripting | `Scripts`, `ScriptEdit` (Monaco), `ScriptLog` | | Ops/admin | `Deployments`, `Certificates`, `Reservations`, `RoleGrants`, `Account`, `Login` | ### API surface - `POST /api/deployments` (`Api/DeployApiEndpoints.cs`) — headless deploy trigger; `AllowAnonymous` but self-gated on `Security:DeployApiKey` via `X-Api-Key` with fixed-time compare, 503 when unconfigured. Clean, well tested. -- `POST /api/script-analysis/{diagnostics,completions,hover,signature-help,format,inlay-hints}` (`ScriptAnalysis/ScriptAnalysisEndpoints.cs`) — Roslyn-backed Monaco language services, gated `Roles = "Administrator,Designer"`. +- `POST /api/script-analysis/{diagnostics,completions,hover,signature-help,format,inlay-hints}` (`ScriptAnalysis/ScriptAnalysisEndpoints.cs`) — Roslyn-backed Monaco language services, gated `Roles = "Administrator,Designer"` (CLAUDE.md now matches — prior C-2 fixed). - SignalR hubs `/hubs/{alerts,driverstatus,fleetstatus,scriptlog}` — all `[Authorize]`; retained for out-of-process clients only. ### State / broadcast patterns @@ -49,13 +82,13 @@ The codebase's own best practice (stated in comments) is `System.Threading.Timer - `Components/Pages/Fleet.razor:171` — `public void Dispose() => _timer?.Dispose();` (sync, no drain). - `Components/Pages/AlarmsHistorian.razor:90` — same. -- `Components/Shared/Drivers/DriverTestConnectButton.razor:68–81` — uses `System.Timers.Timer` with an `async (_, _)` `Elapsed` handler (line 69; effectively async-void) and a sync `Dispose`, directly contradicting the comment convention in `Alerts.razor:243–244`. +- `Components/Shared/Drivers/DriverTestConnectButton.razor:68–81` — uses `System.Timers.Timer` with an `async (_, _)` `Elapsed` handler (line 69; effectively async-void) and a sync `Dispose`, directly contradicting the comment convention in `Alerts.razor:242–243`. **Recommendation:** converge on the `IAsyncDisposable` + `Threading.Timer` idiom in all three; it is already the documented house pattern. ### S-2 (Medium) — `Fleet.razor` auto-refresh has no error handling; DB faults become unobserved task exceptions -`Fleet.razor:112` schedules `_ = InvokeAsync(LoadAsync)`; `LoadAsync` (`Fleet.razor:119–159`) has `try/finally` but **no catch** — a transient SQL failure faults the discarded task silently and the page shows stale data with no error surface. Contrast `Hosts.razor:269–287`, which catches, logs, and degrades gracefully. `AlarmsHistorian.razor:73–78` has the opposite problem: a bare `catch { }` that leaves the page on "Loading…" forever (the comment admits the role-gating message is a TODO). +`Fleet.razor:112` schedules `_ = InvokeAsync(LoadAsync)`; `LoadAsync` (`Fleet.razor:120–159`) has `try/finally` but **no catch** — a transient SQL failure faults the discarded task silently and the page shows stale data with no error surface. Contrast `Hosts.razor:269–287`, which catches, logs, and degrades gracefully. `AlarmsHistorian.razor:73–78` has the opposite problem: a bare `catch { }` that leaves the page on "Loading…" forever (the comment admits the role-gating message is a TODO). **Recommendation:** copy the `Hosts.LoadConfigAsync` catch-log-degrade shape into `Fleet.LoadAsync`; give `AlarmsHistorian` an explicit "no historian on this node role" state. @@ -69,8 +102,8 @@ The codebase's own best practice (stated in comments) is `System.Threading.Timer `UnsTreeService` implements RowVersion concurrency rigorously (22 `OriginalValue` sites, 10 `DbUpdateConcurrencyException` handlers), and the *edit* paths carry the RowVersion loaded when the modal opened — correct. But the *delete* paths load a **fresh** DTO immediately before deleting and pass its current RowVersion: -- `GlobalUns.razor:279–345` (`ConfirmDeleteAsync` — Area/Line/Equipment branches, e.g. line 293). -- `EquipmentPage.razor:370–380, 415–425, 460–470` (`DeleteTag`/`DeleteVirtualTag`/`DeleteAlarm`). +- `Components/Pages/Uns/GlobalUns.razor:279–326` (`ConfirmDeleteAsync` — Area/Line/Equipment branches, e.g. line 296). +- `Components/Pages/Uns/EquipmentPage.razor:370–377, 415–422, 460–467` (`DeleteTag`/`DeleteVirtualTag`/`DeleteAlarm`). The guard therefore always passes: a concurrent edit between rendering the row and clicking Delete is silently destroyed. The comments ("Load the tag fresh to capture its current RowVersion") show this is deliberate but it makes delete a *last-writer-wins* operation while updates are *first-writer-wins* — an inconsistent contract. @@ -78,7 +111,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a ### S-5 (Medium) — Destructive actions on `EquipmentPage` and `ScriptEdit` have no confirmation -`GlobalUns` has a proper delete-confirm modal (`GlobalUns.razor:71–98`) and `DriverStatusPanel` has an inline Restart confirm (`DriverStatusPanel.razor:119–131`). But `EquipmentPage.razor:172/217/263` delete a tag/virtual-tag/alarm on a single click, and `ScriptEdit.razor:140` deletes a script the same way. +`GlobalUns` has a proper delete-confirm modal (`GlobalUns.razor:71–98`) and `DriverStatusPanel` has an inline Restart confirm (`DriverStatusPanel.razor:119–131`). But `EquipmentPage.razor:172/217/263` delete a tag/virtual-tag/alarm on a single click, and `ScriptEdit.razor:68` (`DeleteAsync`, line 140) deletes a script the same way. **Recommendation:** reuse the `GlobalUns` confirm-modal pattern (or a shared `ConfirmButton` component) for all deletes. @@ -97,6 +130,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a - Event-handler hygiene is excellent: subscribe-before-read TOCTOU ordering (`Alerts.razor:154–159`), unsubscribe-before-timer-drain dispose ordering, capture-then-invoke in `InProcessBroadcaster.SetConnected` (`IInProcessBroadcaster.cs:86–103`). - `AdminOperationsActor` access is exclusively via `Ask` through the singleton proxy — no shared mutable state in the AdminUI layer itself; the snapshot store and broadcaster are the only singletons and both are lock/`ConcurrentDictionary`-guarded. - `MonacoEditor.razor` disposes cleanly and distinguishes `JSDisconnectedException` (silent) from real `JSException` (logged). +- No `MarkupString`/raw-HTML rendering anywhere — no XSS-via-render surface. The CSV import is paste-textarea (`ImportEquipmentModal.razor:30–32`), so upload-size abuse is bounded by the SignalR message limit rather than an unbounded file stream. --- @@ -120,7 +154,7 @@ The guard therefore always passes: a concurrent edit between rendering the row a ### P-4 (Low) — `Deployments.razor:98` runs `ConfigComposer.SnapshotAndFlattenAsync` (full config snapshot + hash) on every page load and after every deploy, purely to compute the drift badge. On a large fleet this is the page's dominant cost. -### P-5 (Low) — No `` anywhere; lists are instead bounded by design (`Alerts` cap 200, `Deployments` `Take(50)`, cluster-scoped tables). Adequate. Two nits: `Alerts.razor:63` renders rows without `@key` while prepending (`Insert(0, …)`), forcing whole-table diffs per event ( `EquipmentPage` tables do use `@key`); the `UnsTree` filter re-runs the recursive `VisibleUnder` walk per render (self-documented as bounded/cheap — agreed). +### P-5 (Low) — No `` anywhere; lists are instead bounded by design (`Alerts` cap 200, `Deployments` `Take(50)`, cluster-scoped tables). Adequate. Two nits: `Alerts.razor:63` renders rows without `@key` while prepending (`Insert(0, …)`), forcing whole-table diffs per event (`EquipmentPage` tables do use `@key`); the `UnsTree` filter re-runs the recursive `VisibleUnder` walk per render (self-documented as bounded/cheap — agreed). ### P-6 (Low) — `inlay-hints` round-trips for a documented no-op @@ -132,23 +166,21 @@ The guard therefore always passes: a concurrent edit between rendering the row a ### C-1 (High) — Three authorization idioms coexist, and the largest mutating surface is effectively ungated +*(Re-verified at f6eaa267 by enumerating every `@attribute [Authorize…]` in `Components/Pages/` — unchanged. STATUS.md confirms no remediation branch targeted this; it was action-list item 5, unscheduled.)* + Observed gates: | Idiom | Where | |---|---| -| `Roles = "Administrator,Designer"` | `Deployments.razor:12`, `Scripts.razor`, `ScriptEdit.razor:5`, `ScriptAnalysisEndpoints.cs:18` | +| `Roles = "Administrator,Designer"` | `Deployments.razor:12`, `Scripts.razor:2`, `ScriptEdit.razor:5`, `ScriptAnalysisEndpoints.cs:18` | | `Policy = "FleetAdmin"` | `RoleGrants.razor:2` (page), `Certificates.razor:90,186` (per-action) | -| `Policy "DriverOperator"` (imperative) | `Alerts.razor:165`, `DriverStatusPanel.razor:166` | -| **bare `[Authorize]`** | **everything else** — `GlobalUns`, `EquipmentPage`, `ClusterEdit`, `NodeEdit`, `NamespaceEdit`, `AclEdit`, all 8 driver pages, `Reservations` | +| `Policy "DriverOperator"` (imperative) | `Alerts.razor:165`, `DriverStatusPanel.razor:166`, `OpcUaClientAddressPickerBody.razor:75`, `GalaxyAddressPickerBody.razor:124` | +| **bare `[Authorize]`** | **everything else** — `GlobalUns`, `EquipmentPage`, `ClusterEdit` (+ all cluster sub-pages), `NodeEdit`, `NamespaceEdit`, `AclEdit`, `NewCluster`, all 8 driver pages + `DriverEditRouter`/`DriverTypePicker`, `Reservations`, `Certificates` (page level) | -Consequence: any authenticated user — including a read-only viewer — can create/edit/delete areas, lines, equipment, tags, virtual tags, scripted alarms, driver instances, ACLs, and namespaces; only the *deploy* button is role-gated. Project memory says global-roles/simplest-authz is a deliberate posture, but the current state isn't "simplest" — it's three idioms plus an unguarded majority, which is exactly the drift that produces surprises later. +Consequence: any authenticated user — including a read-only viewer — can create/edit/delete areas, lines, equipment, tags, virtual tags, scripted alarms, driver instances, ACLs, and namespaces; only the *deploy* button is role-gated. Project memory says global-roles/simplest-authz is a deliberate posture, but the current state isn't "simplest" — it's three idioms plus an unguarded majority, which is exactly the drift that produces surprises later. Note the stakes rose since the prior review: driver pages now author `ResilienceConfig` that (post-#13, `75403caa`) directly shapes production retry/breaker behavior — still behind bare `[Authorize]`. **Recommendation:** centralize policy names as constants (they exist only as string literals today), pick policies over role strings (`FleetAdmin` already means `RequireRole("Administrator")`), and apply one write-gate (`Administrator,Designer` equivalent) to every config-mutating page. This is a one-attribute-per-page change. -### C-2 (Medium) — CLAUDE.md / code drift on the ScriptAnalysis gate - -CLAUDE.md states the `/api/script-analysis/*` endpoints are "gated by the `FleetAdmin` policy"; the code uses `Roles = "Administrator,Designer"` (`ScriptAnalysisEndpoints.cs:17–18`, comment says it deliberately matches the Scripts page). Fix the doc (or converge per C-1). - ### C-3 (Good) — Driver-typed tag-editor pattern is complete and consistent `Uns/TagEditors/TagConfigEditorMap.cs:10–20` and `TagConfigValidator.cs:12–22` cover **all seven** non-Galaxy drivers (Modbus, S7, AbCip, AbLegacy, TwinCat, Focas, OpcUaClient) with parallel keys; Galaxy intentionally uses the live-browse picker + raw `{"FullName": …}` JSON (`TagModal.razor:96–125`). Every editor follows the same shell-over-pure-model shape (`FromJson`/`ToJson`/`Validate`, preserve-unknown-keys), with the parse-only-on-real-change guard against parent re-renders (`ModbusTagConfigEditor.razor:52–57`). The composable root-key seams (`TagHistorizeConfig`, `TagArrayConfig`, `NativeAlarmModel`) merge onto the same JSON without clobbering — a genuinely good design. @@ -159,13 +191,24 @@ All 8 driver pages carry `JsonStringEnumConverter` in their serializer options; ### C-5 (Medium) — Two data-access idioms: service seam vs. EF-in-page -UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test files). But `ScriptEdit.razor:87–156`, `Scripts`, `Deployments.razor:83–100`, `Fleet`, and `Hosts` inject `IDbContextFactory` and run EF (including entity mutation and `SaveChangesAsync`) directly in `@code`. The service-seam pages are the ones with meaningful test coverage; the EF-in-page logic is untestable without a host. New pages copy whichever file is nearest. +UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test files). But `ScriptEdit.razor:87–156`, `Scripts.razor:60`, `Deployments.razor:83–100`, `Fleet.razor:125`, and `Hosts.razor:273` inject `IDbContextFactory` and run EF (including entity mutation and `SaveChangesAsync`) directly in `@code`. The service-seam pages are the ones with meaningful test coverage; the EF-in-page logic is untestable without a host. New pages copy whichever file is nearest. **Recommendation:** declare the service-seam the convention; migrate `ScriptEdit`'s save/delete into the existing `UnsTreeService` script methods (`UpdateScriptSourceAsync` already exists at `IUnsTreeService.cs:502`). ### C-6 (Low) — Naming/marker nits -`"GalaxyMxGateway"` is a bare string literal in `TagModal.razor:311`; the `DataTypes` array (`TagModal.razor:244`) duplicates type lists that exist elsewhere; casing drifts across `TwinCat` (map key) / `TwinCATTagConfigEditor` / `FOCASAddressPickerBody` / `FocasTagConfigEditor`. CSS is disciplined (one `site.css` + shared theme kit; chip/panel classes reused consistently, `chip-warn` vs `chip-caution` both exist — verify both are defined in the theme). +`"GalaxyMxGateway"` is a bare string literal in `TagModal.razor` (comments/branching at lines 98, 262, 275 and the dispatch site); the `DataTypes` array (`TagModal.razor:244`) duplicates type lists that exist elsewhere; casing drifts across `TwinCat` (map key) / `TwinCATTagConfigEditor` / `FOCASAddressPickerBody` / `FocasTagConfigEditor`. CSS is disciplined (one `site.css` + shared theme kit; chip/panel classes reused consistently, `chip-warn` vs `chip-caution` both exist — verify both are defined in the theme). + +### C-7 (Medium, NEW) — `ResilienceFormModel` round-trip is lossy: malformed JSON silently discarded on next edit, unknown keys stripped, and the promised raw-text view never landed + +The per-driver resilience override editor (`Components/Shared/Drivers/DriverResilienceSection.razor`, embedded in all 8 driver pages via `@bind-ResilienceConfig`) violates the preserve-unknown-keys convention its sibling tag editors (C-3) established: + +- `ResilienceFormModel.FromJson` (`ResilienceFormModel.cs:52–74`) maps **only** the known top-level fields and only capability keys present in the fixed `Capabilities` list (line 67 `TryGetValue` drops the rest); `ToJson` (`:78–101`) emits only the model's own fields. Any unknown top-level key or unrecognized capability name in the stored JSON is silently stripped the moment a user edits *any* resilience field (`EmitAsync`, `DriverResilienceSection.razor:60–66`, replaces the bound value with `ToJson()`). +- Worse for the malformed case: `FromJson` catches `JsonException` and returns an **empty form** (`ResilienceFormModel.cs:59`), with the comment "malformed -> empty form; **raw view (next task) shows the text**". That task never landed — the "Raw JSON (advanced)" pane (`DriverResilienceSection.razor:37–40`) is a read-only `
` of `_m.ToJson()`, i.e. the *re-serialized model*, not the stored text. So a malformed/hand-edited `DriverInstance.ResilienceConfig` renders as an apparently-blank form, and the first keystroke in any resilience field overwrites the stored override with the empty model's output — silent data loss with no indication the original existed.
+
+This mattered less when the column was write-only; since #13 (`75403caa`) the artifact read path applies these overrides to production dispatch, so a silently-emptied override silently reverts a driver to tier defaults.
+
+**Recommendation:** adopt the tag-editor round-trip discipline (deserialize to `JsonObject`, overlay known fields, preserve the rest), surface a "stored JSON could not be parsed — editing will replace it" warning when `FromJson` falls back, and make the raw pane show (or edit) the actual stored text. `ResilienceFormModelTests.cs` exists — extend it to pin unknown-key preservation.
 
 ---
 
@@ -173,7 +216,7 @@ UNS pages go through `IUnsTreeService` (testable, RowVersion-aware, 12 test file
 
 ### U-1 — Test coverage: strong C# seams, zero component rendering coverage
 
-443 `[Fact]`/`[Theory]` cases covering: all tag-config models + validator, `UnsTreeService` (structure/tags/vtags/import/equip-token/script CRUD against a test DB), browse session service/registry/reaper, `CertificateStoreManager`, audit writers, `DeployApiEndpoints` auth matrix, driver-page form serialization (the enum bug class), 8 ScriptAnalysis suites (diagnose/completions/hover/format/tag-path/equip-token), `InProcessBroadcaster`, `HostsDriverView`, address builders. That is a deliberately shaped portfolio around the pure seams.
+443 `[Fact]`/`[Theory]` cases (unchanged since the prior review) covering: all tag-config models + validator, `ResilienceFormModel`, `UnsTreeService` (structure/tags/vtags/import/equip-token/script CRUD against a test DB), browse session service/registry/reaper, `CertificateStoreManager`, audit writers, `DeployApiEndpoints` auth matrix, driver-page form serialization (the enum bug class), 8 ScriptAnalysis suites (diagnose/completions/hover/format/tag-path/equip-token), `InProcessBroadcaster`, `HostsDriverView`, address builders. That is a deliberately shaped portfolio around the pure seams.
 
 There is **no bUnit** — 77 razor components have no render/binding coverage, which is the documented repo posture ("no bUnit — live-verify"; the `@`-binding bug class passes all unit tests). The consequence is that everything in `@code` blocks that isn't extracted (e.g. all of `EquipmentPage`'s tab/delete handlers, `GlobalUns`'s modal orchestration, `Alerts`' row-action state machine) is verified only by manual `/run` sessions.
 
@@ -184,7 +227,8 @@ There is **no bUnit** — 77 razor components have no render/binding coverage, w
 - `InlayHints` is a wired no-op endpoint + active client provider (P-6); documented in `ScriptAnalysisService.cs:22–25`.
 - `AlarmsHistorian.razor:73–78` — admits the missing role-gated "no historian on this node" message; page silently shows "Loading…" forever on admin-only nodes.
 - `MonacoEditor.razor:33–37` — `MarkersChanged` is typed `object[]` with a comment that the DTO "is not modelled yet".
-- `GlobalUns.ConfirmDeleteAsync` default branch returns "Delete for this node kind is not yet available." for Tag/VirtualTag nodes (`GlobalUns.razor:~320`) — consistent with equipment-page ownership, but the message leaks a not-implemented posture into the UI.
+- `GlobalUns.ConfirmDeleteAsync` default branch returns "Delete for this node kind is not yet available." for Tag/VirtualTag nodes (`GlobalUns.razor:~324`) — consistent with equipment-page ownership, but the message leaks a not-implemented posture into the UI.
+- `DriverResilienceSection.razor:59` — the "raw view (next task)" comment in `ResilienceFormModel.cs` documents a follow-up that never shipped (see C-7).
 
 ### U-3 — Raw-JSON fallback surfaces
 
@@ -196,24 +240,34 @@ There is **no bUnit** — 77 razor components have no render/binding coverage, w
 
 ~31 `aria-*`/`role=` attributes across 77 components, concentrated in the modals (`role="dialog"`, `btn-close` aria-labels). Gaps: text-glyph expander buttons (`▼`/`▶` in `UnsTree.razor:49`) with no `aria-expanded`/label; modals have no focus trap or Escape handling; live tails (`Alerts`) have no `aria-live` region; tables lack captions/scope. Form labeling is decent (most inputs have `for`/`id` pairs). Acceptable for an internal ops tool; worth a targeted pass on the tree and the modals if operator diversity matters.
 
+### U-5 (NEW) — Resilience runtime status has no AdminUI surface; overrides are authored blind
+
+The remediation merges made per-driver resilience live in production: #10 (`bacea1a4`) wired `CapabilityInvoker` into every `DriverInstanceActor` dispatch site, and #13 (`75403caa`) applies the AdminUI-authored `ResilienceConfig` overrides from the deploy artifact. But `DriverResilienceStatusTracker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs`) still has **no reader** — no AdminUI component, hub, or endpoint consumes it (`grep -rn Resilience src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` matches only the authoring form + driver pages). The planned `/hosts` Stream E.2/E.3 status panels never shipped; the interim observability surface is retry/breaker **log lines** added during #10. `archreview/plans/STATUS.md` explicitly records this as "still open as an Admin-UI follow-on."
+
+Consequence: an operator can now author retry counts, breaker thresholds, and bulkhead limits per driver instance (all 8 driver pages) with zero UI feedback on whether a breaker is open, a bulkhead is queuing, or retries are firing — the only diagnosis path is server logs.
+
+**Recommendation:** ship the deferred `/hosts` resilience panel: expose `DriverResilienceStatusTracker` snapshots through the existing `DriverStatusSignalRBridge`/`IDriverStatusSnapshotStore` pattern (the plumbing idiom already exists) and render breaker/bulkhead state chips beside the driver health chips.
+
 ---
 
 ## Maturity Ratings
 
-| Dimension | Rating (1–5) | Justification |
-|---|---|---|
-| Stability | **4** | Disciplined disposal/marshaling with documented race reasoning and the self-HubConnection ban honored everywhere; docked for three non-draining timers, `Fleet`'s uncaught refresh path, no `ErrorBoundary`, and the RowVersion-refetch delete pattern. |
-| Performance | **3** | Event-push where it matters and bounded lists, but keystroke-grain Monaco interop, per-request Roslyn recompiles ×6 endpoints, per-circuit DB polling, and a full config snapshot per Deployments render. |
-| Conventions | **3** | The tag-editor/validator pattern is exemplary and test-pinned, but authorization is a three-idiom mix with the main mutating surface ungated, plus service-seam vs EF-in-page duality and a CLAUDE.md gate-description drift. |
-| Underdeveloped | **3** | 443 well-aimed seam tests, but zero rendering coverage for 77 components (by policy), several admitted stubs, an unvalidated raw-JSON escape hatch, and thin accessibility. |
+| Dimension | 2026-07-08 | 2026-07-12 | Justification |
+|---|---|---|---|
+| Stability | **4** | **4** | Unchanged code, unchanged assessment: disciplined disposal/marshaling with documented race reasoning and the self-HubConnection ban honored everywhere; docked for three non-draining timers, `Fleet`'s uncaught refresh path, no `ErrorBoundary`, and the RowVersion-refetch delete pattern — all still open. |
+| Performance | **3** | **3** | Unchanged: event-push where it matters and bounded lists, but keystroke-grain Monaco interop, per-request Roslyn recompiles ×6 endpoints, per-circuit DB polling, and a full config snapshot per Deployments render. |
+| Conventions | **3** | **3** | C-2 (doc drift) fixed, but the dominant C-1 three-idiom/ungated-majority finding is untouched, C-5/C-6 remain, and the new C-7 shows the preserve-unknown-keys convention wasn't carried into the resilience editor. |
+| Underdeveloped | **3** | **3** | 443 well-aimed seam tests but still zero rendering coverage (by policy), the same admitted stubs, the unvalidated raw-JSON escape hatch — and the new U-5: production-live resilience behavior authored with no status surface. |
 
 ---
 
 ## Top Recommendations (ordered)
 
-1. **C-1:** Centralize policy constants and put a write-role gate on every config-mutating page (`/uns`, equipment, cluster/driver/ACL/namespace editors, Reservations).
-2. **S-4 + S-5:** Make deletes carry the rendered RowVersion (or document last-writer-wins) and add confirm dialogs on `EquipmentPage`/`ScriptEdit` deletes.
-3. **S-3:** Add an `ErrorBoundary` around `@Body`.
-4. **P-1:** Debounce Monaco's value push to .NET.
-5. **S-1/S-2:** Converge the three straggler timers on the async-dispose idiom and give `Fleet.LoadAsync` the `Hosts`-style catch.
-6. **C-5/U-1:** Declare the service-seam pattern canonical and keep extracting `@code` logic into testable classes.
+1. **C-1:** Centralize policy constants and put a write-role gate on every config-mutating page (`/uns`, equipment, cluster/driver/ACL/namespace editors, Reservations) — now also guarding production-live resilience overrides.
+2. **C-7:** Make `ResilienceFormModel` round-trips non-lossy (preserve unknown keys, warn on unparseable stored JSON) before an edit silently reverts a live driver to tier defaults.
+3. **S-4 + S-5:** Make deletes carry the rendered RowVersion (or document last-writer-wins) and add confirm dialogs on `EquipmentPage`/`ScriptEdit` deletes.
+4. **S-3:** Add an `ErrorBoundary` around `@Body`.
+5. **U-5:** Ship the deferred `/hosts` resilience status panel via the existing snapshot-store idiom.
+6. **P-1:** Debounce Monaco's value push to .NET.
+7. **S-1/S-2:** Converge the three straggler timers on the async-dispose idiom and give `Fleet.LoadAsync` the `Hosts`-style catch.
+8. **C-5/U-1:** Declare the service-seam pattern canonical and keep extracting `@code` logic into testable classes.
diff --git a/archreview/05-protocol-drivers.md b/archreview/05-protocol-drivers.md
index 2f355571..884cd5bf 100644
--- a/archreview/05-protocol-drivers.md
+++ b/archreview/05-protocol-drivers.md
@@ -1,7 +1,16 @@
 # Architecture Review 05 — Protocol Drivers
 
-**Date:** 2026-07-08
-**Commit:** `9cad9ed0`
+**Date:** 2026-07-12
+**Commit:** `f6eaa267` (master, clean tree)
+**Updates:** the 2026-07-08 review at `9cad9ed0`. Since then the arch-review
+remediation branches merged to master; the delta touching this domain is
+exactly: Critical 3 — S7 lazy reconnect (`25c0c6f6`, merged `dfc02af3`),
+Critical 4 — TwinCAT native-notification replay (`af318fb4`, merged
+`8f7b0017`), the OTOPCUA0001 analyzer wiring (`f0082af5` — pragma/NoWarn-only
+in this domain: two driver-internal self-call pragmas in
+`AbCipAlarmProjection.cs`, one in `S7Driver.PollOnceAsync`, `NoWarn` in the
+six CLI csprojs), plus the new reconnect test suites under `tests/Drivers/`.
+No other driver file changed.
 **Scope:** the seven cross-platform protocol drivers and their satellites —
 `Driver.Modbus` (+ `.Addressing`, `.Contracts`), `Driver.S7` (+ `.Contracts`),
 `Driver.AbCip` (+ `.Contracts`), `Driver.AbLegacy` (+ `.Contracts`),
@@ -11,14 +20,57 @@
 `tests/Drivers/` and `tests/Drivers/Cli/` were assessed for coverage only.
 Galaxy and the Historian drivers are out of scope.
 
-**Method:** Modbus was read exhaustively as the reference driver (it is the
-documented origin of the shared `PollGroupEngine` and the richest read
-planner); each other driver's main client/poller/factory/probe/contracts
-files were then read in full and diffed structurally against the Modbus
-shape. Cross-cutting greps covered TODO/HACK/NotImplemented, JSON
-serializer options, `PollGroupEngine` construction, and interface
-declarations. File references are repo-relative; drivers are abbreviated to
-their suffix (e.g. `Driver.S7/S7Driver.cs`).
+**Method:** every prior finding was re-verified against current code (the
+delta above means findings in untouched files carry forward with their line
+references intact; only `S7Driver.cs` and `TwinCATDriver.cs` shifted). The
+two remediation commits were read in full and adversarially — the fresh
+findings STAB-14..17 come from that reading. The original method (Modbus as
+the exhaustively-read reference driver, structural diffing of each sibling)
+stands from the 2026-07-08 pass.
+
+---
+
+## Prior-finding status (9cad9ed0 → f6eaa267)
+
+| ID | One-liner | Status @ f6eaa267 |
+|---|---|---|
+| STAB-1 | S7 has no reconnect path at all | **FIXED** — `25c0c6f6`: `IS7Plc`/`IS7PlcFactory` seam (`Driver.S7/IS7Plc.cs`), lazy `EnsureConnectedAsync` under `_gate` (`S7Driver.cs:1188-1224`), connection-fatal classification `MarkConnectionDeadIfFatal`/`IsS7ConnectionFatal` (`:1226-1251`), probe loop as reconnect backstop (`:1532-1536`). 4 unit guards (`S7DriverReconnectTests`) + env-gated live bounce test (`S7_1500ReconnectTests`), live-verified against python-snap7. **However, the fix itself introduces new findings STAB-14 (High) and STAB-15 (High)** — see below. |
+| STAB-2 | TwinCAT native subs silently orphaned after reconnect | **FIXED** — `af318fb4`: `NativeRegistration` replayable-intent registry per device (`TwinCATDriver.cs:551-613, 921`), `EnsureConnectedUnderGateAsync` replays every intent onto a fresh client (`:731-773, 784-823`), probe-failure `RecycleClientAsync` closes the `IsConnected`-lies compounding hole (`:631-643, 826-843`). Register and replay both run under `ConnectGate` — verified no register/replay double-registration race (intent stored only after the handle is live, `:508-520`). Unit-only verification (4 tests, fake client) — no TC3 fixture exists, accepted. **Residuals: STAB-16 (Medium) and STAB-17 (Low)** — see below. Data-path reads on a wire-dead-but-port-open client still fail Bad until the next probe tick recycles (bounded by probe interval — acceptable); the wrapper's pre-gate fast path (`:709-710`) can hand a data-path caller a client the probe is concurrently disposing (transient Bad read — acceptable). |
+| STAB-3 | Modbus transport desyncs after response timeout / TxId mismatch | **STILL OPEN** — `ModbusTcpTransport.cs:155-165, 233-237, 257-261` unchanged. Note: STAB-15 shows the new S7 reconnect path reproduces this same failure mode. |
+| STAB-4 | AbCip concurrent ops on shared libplctag handles, no lock | **STILL OPEN** — `AbCipDriver.cs:544-575, 614-637, 708-726` unchanged; alarm-projection read loop now `AbCipAlarmProjection.cs:230-232` (pragma lines shifted it +4). |
+| STAB-5 | FOCAS fixed-tree caches are plain `Dictionary`s mutated cross-thread | **STILL OPEN** — `FocasDriver.cs:1184-1194, 752-839, 912-945` unchanged. |
+| STAB-6 | AbLegacy probe-shutdown race; S7 unsubscribe race | **STILL OPEN** — AbLegacy unchanged (`AbLegacyDriver.cs:118, 156-170`); S7 `UnsubscribeAsync` still cancels + disposes the CTS without draining `PollTask` (now `S7Driver.cs:1350-1354`; the loop's `Task.Delay` still catches only OCE, `:1396-1397`). |
+| STAB-7 | `InitializeAsync` ignores `driverConfigJson` (Modbus, AbLegacy); TwinCAT empty-config quirk | **STILL OPEN** — all three sites unchanged (`ModbusDriver.cs:167-197`, `AbLegacyDriver.cs:78-153`, `TwinCATDriver.cs:89-94`). |
+| STAB-8 | No reconnect backoff fleet-wide (except Modbus transport, S7 poll loop) | **STILL OPEN — and slightly worse on S7**: the new lazy reconnect attempts a full TCP connect (bounded only by `_options.Timeout`) on *every* data call while the PLC is down, with no attempt throttle; poll-loop backoff shields subscriptions but not server-initiated reads/writes or the probe. |
+| STAB-9 | `PollGroupEngine` `onError` sink dead — no driver passes it | **STILL OPEN** — verified all five construct without it (`ModbusDriver.cs:115`, `AbCipDriver.cs:133`, `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72`). |
+| STAB-10 | AbLegacy never evicts failed tag handles on the data path | **STILL OPEN** — `AbLegacyDriver.cs:246-274` unchanged. |
+| STAB-11 | RMW locks don't cover direct parent writes; AbCip DINT / TwinCAT 4-byte assumptions | **STILL OPEN** — all sites unchanged (`AdsTwinCATClient.cs` untouched by the replay commit). |
+| STAB-12 | Blocking/uncancellable teardown paths (FOCAS close-PDU, TwinCAT blocking Connect) | **STILL OPEN — and compounded**: the new `RecycleClientAsync` waits on `ConnectGate` with `CancellationToken.None` (`TwinCATDriver.cs:835`), so a connect wedged under the gate now also wedges the probe loop uncancellably. |
+| STAB-13 | (Positive) write-outcome surfacing universal; OpcUaClient reconnect machine strongest | **STILL HOLDS** — S7 write path now `S7Driver.cs:981-1083` (per-item statuses preserved through the reconnect wrapper); all other cites unchanged. |
+| PERF-1 | Five of seven drivers read one tag per wire round-trip | **STILL OPEN** — no batching work landed. |
+| PERF-2 | AbCip whole-UDT read exists in two never-joined halves | **STILL OPEN** — planner/template-cache unchanged. |
+| PERF-3 | S7 forked poll loop diffs arrays by reference → publishes every tick | **STILL OPEN** — now `S7Driver.cs:1469` (`Equals(lastSeen?.Value, current.Value)`). |
+| PERF-4 | OpcUaClient serializes all traffic on one global gate | **STILL OPEN** — `OpcUaClientDriver.cs` untouched. |
+| PERF-5 | OpcUaClient discovery enrichment un-chunked Read + silent degrade | **STILL OPEN**. |
+| PERF-6 | Assorted per-tick allocation churn (TwinCAT re-parse, etc.) | **STILL OPEN** — TwinCAT symbol re-parse now `TwinCATDriver.cs:220, 284, 468`. |
+| PERF-7 | Deadband only in Modbus | **STILL OPEN**. |
+| CONV-1 | S7 forked the poll loop; fork diverged both directions | **STILL OPEN** — fork now `S7Driver.cs:1373-1412` (backoff `:1447`, cap `:1363`); neither direction merged. The fork's bare OCE-return is now load-bearing for the new STAB-14. |
+| CONV-2 | Three config-parse strictness tiers; silent enum defaulting in all six equipment-tag parsers | **STILL OPEN** — all six `ReadEnum` copies + Modbus probe shape unchanged. |
+| CONV-3 | Four factories take no `ILoggerFactory` → NullLogger in production | **STILL OPEN** — verified `S7DriverFactoryExtensions.cs:19` still `Register(DriverFactoryRegistry)` only; ditto TwinCAT/AbCip/FOCAS. Now more costly: the new S7 reconnect and TwinCAT replay log recovery *only* via `_logger` — invisible in production for S7/TwinCAT until this lands. |
+| CONV-4 | `ResolveHost` mis-keys equipment-tag refs in every multi-device driver | **STILL OPEN** — TwinCAT's now `TwinCATDriver.cs:689-696`; others unchanged. |
+| CONV-5 | Health-field publication idioms drift (plain fields in S7/AbCip/TwinCAT/OpcUaClient) | **STILL OPEN** — S7's new `_plcDead`/`_initialized` are gate-guarded (fine), but `_health` remains a plain field written from data paths, poll loops and the probe. |
+| CONV-6 | OpcUaClient.Contracts drags the OPC UA SDK | **STILL OPEN** (previously-deferred NamespaceMap move). |
+| CONV-7 | CLI harness divergences | **STILL OPEN** — only change is the uniform `NoWarn OTOPCUA0001` (wire-level projects, commented; consistent with the analyzer triage — neutral). |
+| CONV-8 | Dead config knobs + stale scaffold docs; no options `Validate()` | **STILL OPEN**. |
+| UNDER-1 | FOCAS advertises writes it cannot perform | **STILL OPEN**. |
+| UNDER-2 | OpcUaClient `UnsMappingTable` mandatory-but-unconsumed | **STILL OPEN**. |
+| UNDER-3 | OpcUaClient alarm filter compares mismatched NodeId encodings | **STILL OPEN**. |
+| UNDER-4 | S7 authored-array/write/UInt32 gaps | **STILL OPEN** — refs shifted: array-write guard `S7Driver.cs:~1088-1110`, wide-type arrays out of scope `:344, :357`, `UInt32 → DriverDataType.Int32` lossy map `:1315`; `S7TagDto` still has no `ArrayCount`. |
+| UNDER-5 | AbCip alarm-ack outcomes discarded | **STILL OPEN** — `AbCipAlarmProjection.cs:149-151` (fire-and-forget now sits inside an intentional-self-call pragma — the *dispatch* annotation is correct, the discarded per-item outcomes are still the finding). |
+| UNDER-6 | Equipment-tag parsers lag the authored-tag feature set | **STILL OPEN**. |
+| UNDER-7 | Live-risk assumptions documented but ungated | **STILL OPEN** — note the TwinCAT replay path is itself unit-only (no TC3 fixture), joining this list's "needs bench time" set. |
+| UNDER-8 | Test coverage codec-heavy, failure-path-light | **PARTIALLY FIXED** — S7 reconnect gap closed (`S7DriverReconnectTests` ×4 incl. fatal-vs-data-error classification + reopen-fails-then-recovers; live `S7_1500ReconnectTests` double-gated on sim + bounce cmd); TwinCAT gained `TwinCATReconnectReplayTests` ×4 (replay, multi-tag, unsub-after-reconnect, probe-recycle — fake client). Still absent: AbLegacy reconnect tests, S7/AbCip concurrency tests, connect-*timeout* (vs connect-refused) outage coverage — which is exactly where the new STAB-14 lives. |
+| UNDER-9 | OpcUaClient 2,154-line monolith; PKCS#12-only identity | **STILL OPEN**. |
 
 ---
 
@@ -66,6 +118,15 @@ Every protocol driver is expected to follow the same template:
    and `DisposeAsync`: cancel loops, await them, dispose CTSs, clear
    caches, dispose the transport.
 
+Since the prior review the template gained a ninth expectation, enforced
+tree-wide by the OTOPCUA0001 analyzer: **driver capability calls are wrapped
+by `CapabilityInvoker` at the dispatch layer**, and a driver's *internal*
+self-calls (its own poll loop, its own alarm projection reading through its
+own `ReadAsync`) carry an explanatory `#pragma` marking them intentional
+(`S7Driver.cs:1460-1462`, `AbCipAlarmProjection.cs:149-151, 230-232`). This
+matters here because dispatch-level Polly *timeouts* can now cancel driver
+I/O mid-PDU — see STAB-15.
+
 Modbus adds the most advanced read planner in the fleet: block-read
 coalescing (`MaxReadGap`), auto-prohibition of failing ranges with
 bisection re-probing (`ModbusDriver.cs:687-795, 553-677`), chunking over
@@ -76,12 +137,12 @@ and `WriteOnChangeOnly` suppression with read-side invalidation.
 
 | Aspect | Modbus (ref) | S7 | AbCip | AbLegacy | TwinCAT | FOCAS | OpcUaClient |
 |---|---|---|---|---|---|---|---|
-| LOC (driver proj) | 2,374 | 1,979 | 3,762 | 1,830 | 2,261 | 5,007 | 2,347 |
+| LOC (driver proj) | 2,374 | ~2,170 | 3,762 | 1,830 | ~2,460 | 5,007 | 2,347 |
 | `IPerCallHostResolver` | yes | **no** | yes | yes | yes | yes | no (single endpoint, defensible) |
 | Extra capabilities | — | — | `IAlarmSource` | — | `IRediscoverable` | `IAlarmSource` | `IAlarmSource`, `IHistoryProvider` |
 | Subscription model | PollGroupEngine | **bespoke forked loop** | PollGroupEngine | PollGroupEngine | native ADS push (default) + PollGroupEngine fallback | PollGroupEngine | **real OPC UA MonitoredItems** |
 | `onError` sink passed to engine | no | n/a | no | no | no | no | n/a |
-| Reconnect story | transport auto-reconnect + geometric backoff | **none** | evict + recreate handle, no backoff | **no eviction on data path** | lazy per-call reconnect, **native subs orphaned** | lazy reconnect per tick, no backoff | `SessionReconnectHandler` + re-arm (best) |
+| Reconnect story | transport auto-reconnect + geometric backoff | **NEW: lazy reconnect via `IS7Plc` seam + probe backstop** (but see STAB-14/15) | evict + recreate handle, no backoff | **no eviction on data path** | **NEW: lazy reconnect + native-sub intent replay + probe-failure recycle** | lazy reconnect per tick, no backoff | `SessionReconnectHandler` + re-arm (best) |
 | Read batching | block coalescing + bisection | per-tag | per-tag; UDT planner **half-built** | per-tag | per-tag (no ADS sum-read) | per-address, single-flight socket | batched `ReadValueIdCollection` |
 | Deadband / WriteOnChangeOnly | yes / yes | no / no | no / no | no / no | no / no | no / no | n/a (passthrough) |
 | Bit-level writes | RMW + per-register lock | native S7 bit write | RMW + per-parent lock (DINT-only assumption) | RMW + per-parent lock, width-aware | RMW + per-parent lock (**4-byte width assumption unverified**) | n/a (read-only backend) | n/a |
@@ -101,38 +162,92 @@ field scenario; **High** = correctness or stability defect likely to bite;
 **Medium** = architectural debt / divergence with concrete failure modes;
 **Low** = hygiene.
 
+STAB-1 and STAB-2 are fixed (see the status table); their residual defects
+are the new STAB-14..17 below.
+
 ### 1. Stability
 
-**STAB-1 — Critical — S7 has no reconnect path at all.**
-`Plc.OpenAsync` is called exactly once, in `InitializeAsync`
-(`Driver.S7/S7Driver.cs:165-167`). Read/write failures set `Degraded` and
-keep using the dead `Plc` (`S7Driver.cs:479-483, 995-1001`); the probe loop
-detects the outage, transitions `Stopped` (`S7Driver.cs:1351-1383`) — and
-keeps probing the same dead connection forever. A transient PLC reboot or
-network blip permanently kills the driver instance until an external
-redeploy/restart. Every sibling driver has *some* recovery path (Modbus:
-transport-level reconnect+backoff; TwinCAT/FOCAS: lazy recreate; AbCip:
-handle eviction). *Recommendation:* on socket-level `PlcException`, dispose
-and lazily reopen the `Plc` under `_gate` (mirror TwinCAT's
-`EnsureConnectedAsync`), or let the probe loop own recycle-on-Stopped.
-Note the test suite has **zero reconnect tests for S7** (see TEST-1), which
-is how this shipped.
+**STAB-14 — High — NEW — S7's reconnect wrapper rethrows connect-timeout
+cancellation, and the poll loop's bare OCE catch then permanently kills the
+subscription.** `EnsureConnectedAsync` bounds the reopen with a linked
+`CancelAfter(_options.Timeout)` CTS (`S7Driver.cs:1206-1208`). When the PLC
+is unreachable-but-not-refusing — pulled cable, powered-off device on a
+switched network, firewall drop: the *classic* field outage Critical 3
+targeted — `OpenAsync` throws `TaskCanceledException`. `ReadAsync`'s
+ensure-wrapper catches `OperationCanceledException` and rethrows
+**unconditionally** (`:488`, no `when (cancellationToken.IsCancellationRequested)`
+filter; `WriteAsync` same at `:1000`), so the timeout escapes as OCE to the
+caller. In the poll loop that caller is `PollLoopAsync`, whose
+`catch (OperationCanceledException) { return; }` (`:1383, :1404`) treats
+*any* OCE as teardown and exits — silently: no log, no health change, no
+`HandlePollFailure`. Net: after a connect-timeout outage, S7 reads and
+writes self-heal (the very fix), but **every S7 subscription poll loop that
+ticked during the outage is permanently dead until redeploy** — data-loss in
+the exact scenario class Critical 3 was meant to close. The live bounce test
+(`S7_1500ReconnectTests`) cannot catch this: `docker restart` produces
+connection-*refused* (`SocketException` → the degrade-to-Bad path the unit
+tests cover), never connect-*timeout*. *Recommendation:* filter the
+wrapper's OCE rethrow on the caller's token (timeout-OCE joins the
+degrade-to-`BadCommunicationError` path), and filter the poll loop's OCE
+catches on `ct.IsCancellationRequested`; add a fake-factory unit test where
+`OpenAsync` honours a token that only the `CancelAfter` fires.
 
-**STAB-2 — Critical — TwinCAT native subscriptions are silently orphaned
-after reconnect.** Native ADS notifications are the *default* mode
-(`Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs:31`). When
-`EnsureConnectedAsync` discards a stale client
-(`Driver.TwinCAT/TwinCATDriver.cs:615-619`), `AdsTwinCATClient.Dispose`
-clears its notification table (`AdsTwinCATClient.cs:422-436`) and nothing
-re-runs `AddNotificationAsync` on the replacement client — all subscribed
-data flow stops with no Bad status and no error. Compounding: the fast path
-keys on `IsConnected` (`TwinCATDriver.cs:606`), which reflects the local
-AMS port, not wire liveness, so the replacement path may never even
-trigger; and a probe failure never recycles the client
-(`TwinCATDriver.cs:542-544`). *Recommendation:* record registration intent
-(symbol, interval, handler) per subscription and replay it whenever the
-per-device client instance changes; make probe-detected outage force a
-client recycle.
+**STAB-15 — High — NEW — S7's connection-fatal classification misses
+framing/desync errors and cancellation-during-I/O, reproducing Modbus's
+STAB-3 desync; and the probe never marks the handle dead.**
+`IsS7ConnectionFatal` (`S7Driver.cs:1239-1251`) treats only
+`SocketException`/`IOException`/`ObjectDisposedException` (walking inners)
+and `PlcException{ErrorCode.ConnectionError}` as fatal. Two gaps:
+(a) a caller-token cancellation mid-PDU — server request deadline, or a
+dispatch-layer Polly timeout now that `CapabilityInvoker` wraps driver calls
+— abandons a half-read ISO-on-TCP response on the socket; the connection is
+kept (OCE is not classified), and the next call reads the stale bytes.
+S7.Net then surfaces framing faults (`TPKTInvalidException`,
+`PlcException` `WrongNumberReceivedBytes`, `InvalidDataException`) — none of
+which are classified fatal either — so the single gated connection stays
+**permanently desynchronized**, returning `BadDeviceFailure`/
+`BadCommunicationError` forever on a socket that a reopen would fix. This is
+the exact Modbus STAB-3 failure mode rebuilt in the new S7 path.
+(b) The probe loop swallows every failure (`:1542`) without calling
+`MarkConnectionDeadIfFatal`, so its backstop role only works when
+`IsConnected` flips false on its own; a wire-dead or desynced handle whose
+`TcpClient.Connected` stays true is re-probed and re-fails forever
+(contrast TwinCAT, whose probe now force-recycles on failure —
+`TwinCATDriver.cs:631-643`). *Recommendation:* classify framing exceptions
+and cancellation-observed-during-I/O as connection-fatal (tear down before
+propagating, mirroring the STAB-3 fix recommendation), and mark-dead from
+the probe's catch.
+
+**STAB-16 — Medium — NEW — TwinCAT replay failure is silent to subscribers
+and never retried while the client stays up.**
+`ReplayNativeRegistrationsAsync` logs and skips a failed intent
+(`TwinCATDriver.cs:801-816`) — deliberately, so one bad symbol can't abort
+recovery of the rest (good) — but the failed registration keeps its dead old
+handle (`SwapHandle` runs only on success), the `OnChange` callback shape
+(`Action`) has no quality channel so nothing can push a Bad
+snapshot, and nothing schedules a retry: replay runs only when a *new client*
+is installed. If the fresh client is healthy and one `AddNotificationAsync`
+failed transiently (ADS notification-quota blip, symbol mid-redeploy), that
+tag's push feed is dead with only a warning log — the same
+silent-subscription failure STAB-2 was about, now scoped to single tags.
+*Recommendation:* on replay failure degrade driver health and emit a
+Bad-quality `OnDataChange` for the affected reference (the driver holds
+handle + reference even though the closure doesn't), and re-attempt failed
+intents on the next probe tick rather than the next reconnect.
+
+**STAB-17 — Low — NEW — Unsubscribe racing a replay leaks a live ADS
+notification.** `NativeRegistration.Dispose` (`TwinCATDriver.cs:608-613`)
+runs gate-free: it removes the intent from the device registry and disposes
+the current handle. A concurrent reconnect-replay has already snapshotted
+the intent array (`:786`) and may complete `AddNotificationAsync` *after*
+the dispose; `SwapHandle` then installs the fresh handle into a registration
+nobody owns — the ADS notification stays registered on the new client for
+its lifetime, firing `OnDataChange` for an unsubscribed handle. Narrow
+window (unsubscribe must race the replay of a reconnect), bounded blast
+radius (one orphan notification per race until the next client swap).
+*Recommendation:* after `SwapHandle` in the replay, re-check
+`device.NativeRegistrations.ContainsKey(reg.Id)` and dispose the new handle
+if absent (or take `ConnectGate` in `Dispose`).
 
 **STAB-3 — High — Modbus transport desynchronizes after a response
 timeout.** `SendAsync` reconnect-retries only on `IsSocketLevelFailure`
@@ -146,7 +261,8 @@ The single-flight connection stays permanently desynchronized until a real
 socket error or the (optional, default-off) idle-disconnect fires.
 *Recommendation:* treat per-op timeout and any framing violation
 (TxId mismatch, truncated header) as connection-fatal: tear down the socket
-before propagating.
+before propagating. Fix this together with STAB-15 — it is the same defect
+class in two drivers now.
 
 **STAB-4 — High — AbCip runs concurrent operations on shared libplctag
 handles with no serialization.** `ReadSingleAsync` / `ReadGroupAsync` /
@@ -154,10 +270,10 @@ handles with no serialization.** `ReadSingleAsync` / `ReadGroupAsync` /
 cached `IAbCipTagRuntime` instances with zero locking
 (`Driver.AbCip/AbCipDriver.cs:544-575, 614-637, 708-726`) while the server
 read path, every poll-group loop, and the alarm-projection loop
-(`AbCipAlarmProjection.cs:228`) can hit the same handle concurrently.
+(`AbCipAlarmProjection.cs:230-232`) can hit the same handle concurrently.
 AbLegacy documents this exact hazard and guards it with a per-runtime
-operation lock (“A libplctag Tag handle is not safe for concurrent
-Read/GetStatus/DecodeValue”, `Driver.AbLegacy/AbLegacyDriver.cs:775-787`);
+operation lock ("A libplctag Tag handle is not safe for concurrent
+Read/GetStatus/DecodeValue", `Driver.AbLegacy/AbLegacyDriver.cs:775-787`);
 the fix never propagated to AbCip. Consequences: torn/wrong values decoded
 with Good status, cross-attributed `GetStatus` results.
 *Recommendation:* port AbLegacy's `GetRuntimeLock` pattern to AbCip.
@@ -183,12 +299,14 @@ immediately disposes the CTS and runtimes without awaiting loop exit
 this identical bug (retained `ProbeTask` + phased shutdown,
 `Driver.AbCip/AbCipDriver.cs:295-298, 332-365`); the fix never propagated
 back. S7 has the mirror-image bug in `UnsubscribeAsync`: cancel + dispose
-the CTS without draining `PollTask` (`Driver.S7/S7Driver.cs:1185-1193`),
+the CTS without draining `PollTask` (`Driver.S7/S7Driver.cs:1350-1354`),
 so `Task.Delay` on the disposed source can throw `ObjectDisposedException`
-that the loop only catches as OCE (`S7Driver.cs:1233-1234`) —
+that the loop only catches as OCE (`S7Driver.cs:1396-1397`) —
 `PollGroupEngine.StopState` exists precisely to prevent this
-(`PollGroupEngine.cs:99-112, 136`). *Recommendation:* await the loop task
-(bounded) before disposing the CTS in both places.
+(`PollGroupEngine.cs:99-112, 136`). (S7's `ShutdownAsync` does drain
+`PollTask` bounded — the gap is specifically per-subscription unsubscribe.)
+*Recommendation:* await the loop task (bounded) before disposing the CTS in
+both places.
 
 **STAB-7 — High — `InitializeAsync` ignores `driverConfigJson` in Modbus
 and AbLegacy.** AbLegacy's `_options` is readonly ctor state and
@@ -196,12 +314,12 @@ and AbLegacy.** AbLegacy's `_options` is readonly ctor state and
 (`Driver.AbLegacy/AbLegacyDriver.cs:16, 78-153`); Modbus likewise uses only
 ctor options (`Driver.Modbus/ModbusDriver.cs:167-197`). S7, AbCip and
 TwinCAT deliberately re-parse (`Driver.AbCip/AbCipDriver.cs:229-236`
-documents “rather than being silently discarded”). Any config change
+documents "rather than being silently discarded"). Any config change
 delivered through `ReinitializeAsync` on a live instance is silently
 discarded for these two drivers. TwinCAT has a related quirk: a parsed
 config with zero devices AND zero tags is silently discarded in favour of
 ctor options (`Driver.TwinCAT/TwinCATDriver.cs:89-94`), so an intentional
-“empty the driver” redeploy is ignored. *Recommendation:* make config
+"empty the driver" redeploy is ignored. *Recommendation:* make config
 re-parse on initialize a template requirement; add a consuming test per
 driver.
 
@@ -210,12 +328,17 @@ transport and S7's poll loop.** A dead device pays: AbCip — a full `Tag`
 create + Forward Open per tag per poll tick (evict-recreate,
 `AbCipDriver.cs:891-897`); FOCAS — a full two-socket reconnect attempt per
 tick of *each* of three loops (`Driver.FOCAS/FocasDriver.cs:1089-1120`);
-TwinCAT — a connect attempt per call (`TwinCATDriver.cs:602-651`).
+TwinCAT — a connect attempt per call (`TwinCATDriver.cs:706-721`); **S7
+(new) — a full TCP connect attempt bounded only by `_options.Timeout` on
+every read/write batch and every probe tick while the PLC is down**
+(`S7Driver.cs:1188-1224` retries unconditionally on each call; the poll
+loop's capped backoff `:1393-1397` shields subscriptions only).
 `PollGroupEngine` itself polls at a fixed cadence regardless of failures —
-S7's bespoke loop is the only subscriber-side backoff in the fleet
-(`S7Driver.cs:1284-1293`, capped 30 s). *Recommendation:* add optional
-failure backoff to `PollGroupEngine` (adopt S7's `ComputeBackoffDelay`)
-and a small connect-attempt throttle to the lazy-reconnect drivers.
+S7's bespoke loop is the only subscriber-side backoff in the fleet.
+*Recommendation:* add optional failure backoff to `PollGroupEngine` (adopt
+S7's `ComputeBackoffDelay`, `S7Driver.cs:1447`) and a small connect-attempt
+throttle to the lazy-reconnect drivers (S7's `EnsureConnectedAsync` is now
+the natural home for a last-attempt timestamp).
 
 **STAB-9 — Medium — `PollGroupEngine`'s `onError` sink is dead code: no
 driver passes it.** All five consumers construct the engine without the
@@ -223,7 +346,7 @@ error callback (`ModbusDriver.cs:115`, `AbCipDriver.cs:133`,
 `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72`), so a
 reader exception in a poll tick (e.g. `RequireTransport` after teardown,
 reader contract violation) is silently swallowed — the precise
-“silently-broken subscription” failure mode the engine's own doc comment
+"silently-broken subscription" failure mode the engine's own doc comment
 warns about (`PollGroupEngine.cs:21-25, 164-169`). *Recommendation:* pass
 `onError` routing to the driver's health surface + logger in all five;
 consider making the parameter required.
@@ -254,23 +377,31 @@ flagged as an unverified assumption that will likely fail
 (`Driver.FOCAS/Wire/FocasWireClient.cs:164-175`) — a stalled CNC can wedge
 `ShutdownAsync` indefinitely. TwinCAT's `ConnectAsync` calls the blocking
 SDK `Connect` and ignores both its token and timeout parameter for the
-connect itself (`AdsTwinCATClient.cs:73-80`).
+connect itself (`AdsTwinCATClient.cs:73-80`). **New compounding:** the
+probe loop's `RecycleClientAsync` waits on `ConnectGate` with
+`CancellationToken.None` (`TwinCATDriver.cs:835`), so a connect wedged under
+the gate now also wedges the probe loop beyond cancellation.
 
 **STAB-13 — Positive.** Write-outcome surfacing — the seam the server's
 value-revert self-correction depends on — is honoured by every protocol
 driver: per-item `WriteResult` with typed exception→status mapping (Modbus
-`ModbusDriver.cs:897-942`; S7 `S7Driver.cs:921-1006` including the
-PUT/GET-denied → `Faulted` escalation; AbCip `AbCipDriver.cs:732-775`;
-AbLegacy `AbLegacyDriver.cs:348-366`; TwinCAT `TwinCATDriver.cs:332-349`
-with a numerically-verified ADS error table; OpcUaClient passes upstream
-codes verbatim and distinguishes post-dispatch cancellation as
-`BadTimeout` "outcome unknown" — `OpcUaClientDriver.cs:753-779`, the most
-careful non-idempotency handling in the fleet). Nothing is
-fire-and-forget except the AbCip alarm *acknowledge* path (UNDER-5).
-OpcUaClient's reconnect state machine (double-arm guard, re-arm on
-exhaustion, session swap under `_probeLock` —
-`OpcUaClientDriver.cs:1908-2058`) is the strongest reconnect
-implementation in the subsystem.
+`ModbusDriver.cs:897-942`; S7 `S7Driver.cs:981-1083` including the
+PUT/GET-denied → `Faulted` escalation, preserved through the reconnect
+rework; AbCip `AbCipDriver.cs:732-775`; AbLegacy
+`AbLegacyDriver.cs:348-366`; TwinCAT `TwinCATDriver.cs:332-349` with a
+numerically-verified ADS error table; OpcUaClient passes upstream codes
+verbatim and distinguishes post-dispatch cancellation as `BadTimeout`
+"outcome unknown" — `OpcUaClientDriver.cs:753-779`, the most careful
+non-idempotency handling in the fleet). Nothing is fire-and-forget except
+the AbCip alarm *acknowledge* path (UNDER-5). OpcUaClient's reconnect state
+machine (double-arm guard, re-arm on exhaustion, session swap under
+`_probeLock` — `OpcUaClientDriver.cs:1908-2058`) remains the strongest
+reconnect implementation in the subsystem. The two new reconnect paths are
+also well-shaped where it counts: S7's dead-handle flag guarantees
+exactly-once disposal under the gate (`S7Driver.cs:1188-1204`), and
+TwinCAT's register/replay share one `ConnectGate` with
+intent-stored-only-after-handle-live ordering (`TwinCATDriver.cs:508-520`)
+— the obvious double-registration race was designed out.
 
 ### 2. Performance
 
@@ -280,7 +411,7 @@ Only Modbus (block coalescing + auto-prohibition/bisection,
 `ReadValueIdCollection` per batch, `OpcUaClientDriver.cs:640-666`) batch.
 The others are O(N) round trips per poll tick on serialized links:
 - S7: one PDU per tag under one `_gate`d connection
-  (`S7Driver.cs:443-484`); S7comm multi-var reads (S7.Net
+  (`S7Driver.cs:466-545`); S7comm multi-var reads (S7.Net
   `ReadMultipleVarsAsync`) unused.
 - TwinCAT: one ADS call per symbol; ADS **sum-read** — Beckhoff's
   documented batching mechanism — unused (`TwinCATDriver.cs:203-248`).
@@ -298,7 +429,7 @@ reads, AbLegacy spans.
 were never joined.** The read planner
 (`Driver.AbCip/AbCipUdtReadPlanner.cs:32-100`) can collapse N members into
 one parent read, but only via declaration-order layout — off by default
-because it produces “silently-plausible wrong numbers” when member order
+because it produces "silently-plausible wrong numbers" when member order
 diverges (`AbCipDriverOptions.cs:60-72`). Meanwhile the driver already
 fetches and caches *true* member offsets via the CIP Template Object
 (`AbCipDriver.cs:151-182`, `AbCipTemplateCache.cs`) — used only for
@@ -310,7 +441,7 @@ the unsafe declaration-order mode.
 
 **PERF-3 — High — S7's forked poll loop diffs arrays by reference.**
 `PollOnceAsync` compares `Equals(lastSeen?.Value, current.Value)`
-(`S7Driver.cs:1304`); array reads return a fresh CLR array every poll, so
+(`S7Driver.cs:1469`); array reads return a fresh CLR array every poll, so
 every subscribed array tag fires `OnDataChange` **every tick**, flooding
 the dependency mux/DPS downstream. `PollGroupEngine` fixed exactly this
 with `StructuralComparisons` (`PollGroupEngine.cs:203-209`); the bespoke
@@ -352,14 +483,17 @@ in or beside `PollGroupEngine`.
 
 **CONV-1 — High — S7 forked the poll loop instead of using
 `PollGroupEngine`, and the fork has diverged in both directions.**
-`PollGroupEngine`'s own doc claims it serves “Modbus, AB CIP, S7, FOCAS”
+`PollGroupEngine`'s own doc claims it serves "Modbus, AB CIP, S7, FOCAS"
 (`PollGroupEngine.cs:8-9`) but S7 re-ships the entire loop
-(`S7Driver.cs:1165-1307`). The fork is *better* in two ways the engine
-never absorbed (capped exponential failure backoff `:1284-1293`; poll
-failures logged + degrade health `:1258-1274`) and *worse* in two ways the
-engine already fixed (reference-equality array diff — PERF-3; CTS disposed
-without draining the loop — STAB-6). This is the textbook cost of template
-divergence: each side owns fixes the other needs. *Recommendation:*
+(`S7Driver.cs:1373-1412`). The fork is *better* in two ways the engine
+never absorbed (capped exponential failure backoff `:1393-1397, 1447`;
+poll failures logged + degrade health `:1421-1437`) and *worse* in three
+ways the engine already fixed or avoided (reference-equality array diff —
+PERF-3; CTS disposed without draining the loop — STAB-6; **and the bare
+`catch (OperationCanceledException) { return; }` that STAB-14 turns into
+permanent subscription death**). This is the textbook cost of template
+divergence: each side owns fixes the other needs, and the fork's OCE
+handling is now load-bearing for a High-severity defect. *Recommendation:*
 extend `PollGroupEngine` with backoff + onError-driven health, then move
 S7 onto it and delete the fork.
 
@@ -398,9 +532,12 @@ logging to `NullLogger` in production.** S7, TwinCAT, AbCip and FOCAS
 `AbCipDriverFactoryExtensions.cs:21`, `FocasDriverFactoryExtensions.cs:36`)
 even though all four driver classes accept a logger; the bootstrap
 consequently can't pass one
-(`Host/Drivers/DriverFactoryBootstrap.cs:100-107`). All the warning paths
-those drivers carefully log (poll failures, prohibitions, live-risk
-assumptions) are invisible in production. Modbus/AbLegacy/OpcUaClient/
+(`Host/Drivers/DriverFactoryBootstrap.cs:100-107`). This finding got
+*more* expensive since the prior review: the S7 reconnect path
+("S7Driver reconnected", replay tallies) and the TwinCAT replay path
+("re-registered {Replayed}/{Total} native notifications") log recovery and
+partial-replay failures **only** through `_logger` — in production those
+two brand-new recovery mechanisms run blind. Modbus/AbLegacy/OpcUaClient/
 Galaxy have the logger-aware overload. *Recommendation:* one-line fix per
 factory; align all eight `Register` signatures.
 
@@ -409,13 +546,14 @@ every multi-device driver.** The implementations consult only
 `_tagsByName` (authored names); an equipment-tag TagConfig-JSON reference
 never matches and falls back to the driver-level/first-device host
 (`AbCipDriver.cs:483-488`, `AbLegacyDriver.cs:496-501`,
-`TwinCATDriver.cs:585-592`, `FocasDriver.cs:1082-1087`; Modbus
+`TwinCATDriver.cs:689-696`, `FocasDriver.cs:1082-1087`; Modbus
 `ModbusDriver.cs:125-131` has the same shape but per-tag `UnitId` isn't in
 the equipment parser anyway). Per-host Polly bulkhead/circuit-breaker keys
 are therefore wrong for equipment tags on multi-device instances — a
 broken device B can trip device A's breaker and vice versa. Since
 equipment tags are the *primary* authoring model post-Galaxy-standardization,
-the per-call host resolver is effectively inert for the flagship path.
+and the CapabilityInvoker dispatch wiring (task #10) now actually consumes
+these keys in production, the mis-keying is no longer latent.
 *Recommendation:* route `ResolveHost` through
 `EquipmentTagRefResolver` and derive the host from the parsed def
 (`deviceHostAddress` / `unitId`).
@@ -424,14 +562,14 @@ the per-call host resolver is effectively inert for the flagship path.
 template documents `Volatile.Read/Write` (`ModbusDriver.cs:217-228`);
 FOCAS conforms (`FocasDriver.cs:43-46`), AbLegacy uses a `volatile` field
 with rationale (`AbLegacyDriver.cs:29-34`), while S7
-(`S7Driver.cs:124, 1272`), AbCip (`AbCipDriver.cs:95`), TwinCAT and
+(`S7Driver.cs:240`), AbCip (`AbCipDriver.cs:95`), TwinCAT and
 OpcUaClient (`OpcUaClientDriver.cs:96`) use plain fields. Immutable record
 swap means no torn reads — this is a visibility/staleness nit — but four
 different idioms for the same one-line concern is exactly the divergence
 this review is hunting. Related one-offs: TwinCAT declares `Healthy` at
 init with zero wire contact (`TwinCATDriver.cs:115`) where every other
 driver proves the connection first; TwinCAT reads host state without its
-probe lock (`TwinCATDriver.cs:524-525`); AbLegacy never refreshes health
+probe lock (`~TwinCATDriver.cs:554`); AbLegacy never refreshes health
 on successful writes (`AbLegacyDriver.cs:344-346` vs
 `AbCipDriver.cs:725`); AbLegacy silently clobbers duplicate tag names
 (`AbLegacyDriver.cs:91`) where AbCip fails fast
@@ -440,7 +578,7 @@ on successful writes (`AbLegacyDriver.cs:344-346` vs
 **CONV-6 — Medium — Contracts layering is uniform except OpcUaClient,
 which drags the whole OPC UA SDK into its Contracts.** Six Contracts
 projects are POCO options + equipment-tag parser (FOCAS's csproj even
-enforces “NO PackageReference. NO ProjectReference.”), but
+enforces "NO PackageReference. NO ProjectReference."), but
 `Driver.OpcUaClient.Contracts` carries a full OPC UA stack reference
 because `NamespaceMap` lives there
 (`OpcUaClient.Contracts.csproj:9`) — every consumer wanting just the
@@ -462,19 +600,21 @@ TwinCAT exposes a `browse` command despite AbCip also supporting
 controller browse (hardcoded `EnableControllerBrowse=false`,
 `AbCipCommandBase.cs:63`). OpcUaClient has no CLI at all (the server-side
 Client.CLI tests the *server*, not this driver against a third-party
-endpoint). *Recommendation:* hoist value parsing + a standard `Validate()`
+endpoint). The six csprojs gained a commented `NoWarn OTOPCUA0001` in the
+analyzer wiring — consistent and correct for wire-level harnesses.
+*Recommendation:* hoist value parsing + a standard `Validate()`
 into Cli.Common; add an OpcUaClient CLI or document the gap.
 
 **CONV-8 — Low — Dead/no-op config knobs and stale scaffold docs.**
 `DisableFC23` is a documented no-op (`ModbusDriverOptions.cs:83-89`);
 AbCip `ConnectionSize` is plumbed but never applied
 (`AbCipDriverOptions.cs:97-103`); AbLegacy's class doc still says
-read/write “ship in PRs 2 and 3” (`AbLegacyDriver.cs:9-11`); OpcUaClient's
-header still says “PR 66 ships the scaffold: IDriver only”
+read/write "ship in PRs 2 and 3" (`AbLegacyDriver.cs:9-11`); OpcUaClient's
+header still says "PR 66 ships the scaffold: IDriver only"
 (`OpcUaClientDriver.cs:12-14`). No options class in the fleet has a
 `Validate()` method — validation is scattered across factory throws and
 init guards (S7's init guards are the best of breed,
-`S7Driver.cs:314-385`).
+`S7Driver.cs:340-420`).
 
 ### 4. Underdeveloped areas
 
@@ -512,15 +652,17 @@ unreachable.** `S7TagDto` has no `ArrayCount` field and `BuildTag` never
 sets it (`S7DriverFactoryExtensions.cs:80-90, 137-151`) — driver-config
 array tags are silently scalar; arrays only work as equipment tags
 (`S7EquipmentTagParser.cs:73-87`). Array *writes* are unsupported
-(`S7Driver.cs:1014-1017`), wide-type arrays are a stated follow-up
-(`:861-862`), and `UInt32` surfaces lossily as `Int32`
-(`S7Driver.cs:1149-1152`).
+(`S7Driver.cs:~1088-1110`), wide-type arrays are a stated follow-up
+(`:344, :357`), and `UInt32` surfaces lossily as `Int32`
+(`S7Driver.cs:1315`).
 
 **UNDER-5 — Medium — AbCip alarm acknowledge outcomes are discarded.**
 `AcknowledgeAsync` fire-and-forgets the write results
-(`AbCipAlarmProjection.cs:143-149`); a failed ack (undeclared `.Acked`
+(`AbCipAlarmProjection.cs:149-151`); a failed ack (undeclared `.Acked`
 member, non-writable, comms error) is invisible to the operator, health,
 and the Part 9 caller — conditions stick un-acked with no visible cause.
+(The new OTOPCUA0001 pragma at this site correctly annotates the
+*self-call* as intentional; the discarded outcomes remain the defect.)
 Related discovery weak spots: with `EnableControllerBrowse` on, one
 unreachable PLC faults the entire multi-device `DiscoverAsync`
 (uncaught `await foreach`, `AbCipDriver.cs:970-1037`), and browsed tags
@@ -544,30 +686,32 @@ TwinCAT carries two explicit "unverified against real hardware" blocks on
 core paths — array read shape (`AdsTwinCATClient.cs:109-116`) and
 Flat-mode `SubSymbols` population (`:263-277`, struct members could
 silently vanish from discovery) — plus the bit-RMW width assumption
-(STAB-11). AbLegacy's array decode rests on five explicitly unproven
-layout assumptions with no PCCC fixture
+(STAB-11), **and now the entire native-notification replay path, which is
+fake-client-verified only** (no TC3 docker fixture exists; STATUS.md
+acknowledges the unit tests are authoritative). AbLegacy's array decode
+rests on five explicitly unproven layout assumptions with no PCCC fixture
 (`LibplctagLegacyTagRuntime.cs:64-88`). FOCAS's `cnc_getfigure` command id
 and servo-load scaling are sim-validated only
 (`FocasWireClient.cs:391-395, 943-947`). These are honest, well-documented
 debts — but nothing aggregates them into a "needs bench time" checklist.
 
 **UNDER-8 — Medium — Test coverage is codec-heavy, failure-path-light, and
-uneven.** Approximate unit-test counts: Modbus 191 (+71 addressing),
-AbCip 234, FOCAS 143, S7 130, TwinCAT 119, AbLegacy 114, OpcUaClient 93
-(+10 browser). Integration suites are all skip-gated on fixture
-reachability; Modbus's is the richest (30 tests incl. exception injection
-and device-quirk profiles), AbLegacy's the thinnest (2), and TwinCAT's 13
-require a real TC3 XAR runtime — no docker fixture exists, so it never
-runs automated. Systemic gaps: **zero reconnect tests for S7 and
-AbLegacy** (the STAB-1/STAB-10 findings live exactly there), zero
-concurrency tests for S7 and AbCip (STAB-4 lives there; only AbLegacy has
-a dedicated runtime-concurrency suite), and OpcUaClient has the widest
-feature surface on the lowest test density (though its failure-path
-*spread* — reconnect, failover, stale-session race, continuation points —
-is the best). `Cli.Common`'s `DriverCommandBase` has no direct tests.
-There is **zero TODO/HACK/FIXME/NotImplementedException debt** in the
-subsystem — all `NotSupportedException` sites are deliberate fail-fast
-gates with handlers.
+uneven — partially improved.** The prior review's worst gap — **zero S7
+reconnect tests** — is closed: `S7DriverReconnectTests` (4 tests: fatal
+fault → reopen, data-address error → no reopen, raw socket exception →
+reopen, reopen-fails-then-recovers) plus the env-gated live
+`S7_1500ReconnectTests` (a real `docker restart` bounce, live-verified per
+STATUS.md). TwinCAT gained `TwinCATReconnectReplayTests` (4 tests: replay
+onto fresh client, all-tags replay, unsubscribe-after-reconnect disposes +
+stops replaying, probe-failure recycle) — fake-client only, per UNDER-7.
+Remaining systemic gaps: **zero reconnect tests for AbLegacy** (STAB-10
+lives there), zero concurrency tests for S7 and AbCip (STAB-4 lives
+there; only AbLegacy has a dedicated runtime-concurrency suite), **no
+connect-timeout (vs connect-refused) outage test anywhere** — precisely
+the STAB-14 blind spot — and OpcUaClient still has the widest feature
+surface on the lowest test density. `Cli.Common`'s `DriverCommandBase` has
+no direct tests. There remains **zero TODO/HACK/FIXME/
+NotImplementedException debt** in the subsystem.
 
 **UNDER-9 — Low — OpcUaClient is a 2,154-line monolith.** Six separable
 responsibilities share one file (config/PKI/identity, failover sweep,
@@ -584,33 +728,43 @@ loads PKCS#12 while the options doc promises PEM support
 
 ## Maturity ratings
 
-| Dimension | Rating (1-5) | Justification |
-|---|---|---|
-| Stability | **3** | The template (Modbus transport + OpcUaClient reconnect machine) is genuinely robust and write-outcome surfacing is universal, but two Critical connection-loss failures (S7 no-reconnect, TwinCAT orphaned native subs), an AbCip handle-concurrency hazard, and fleet-wide missing backoff mean a routine PLC power-cycle defeats half the fleet. |
-| Performance | **2** | Only 2 of 7 drivers batch reads; the rest are O(N) serialized round trips per poll tick, AbCip's flagship UDT batching is half-built, and deadband exists only in Modbus — the subsystem scales by tag count, not by request count. |
-| Conventions | **3** | The shared seams (PollGroupEngine, EquipmentTagRefResolver, factory/probe pattern, StatusMapper) are real and mostly followed, but the S7 poll-loop fork, three-tier config-parse strictness, six-way `ReadEnum` copy-paste, logger-registration asymmetry, and drifting health idioms show fixes not flowing back to the template. |
-| Underdeveloped areas | **3** | Zero stub/TODO debt and honest live-risk documentation, offset by advertised-but-dead features (FOCAS writes, `UnsMappingTable`, `DisableFC23`, `ConnectionSize`), equipment-tag parsers lagging the primary authoring model, and reconnect/concurrency test gaps precisely where the Critical bugs live. |
+| Dimension | Rating (1-5) | Prior | Justification |
+|---|---|---|---|
+| Stability | **3** | 3 | Both Criticals are genuinely closed — a routine PLC power-cycle no longer permanently kills S7, and TwinCAT native subs now replay — and the fixes are well-shaped (seam-based, gate-disciplined, guard-tested). Held at 3 rather than promoted because the S7 fix ships a High regression in the same scenario class (STAB-14: connect-*timeout* outages permanently kill S7 subscription polls) and reproduces the Modbus desync defect (STAB-15), while the untouched silent-corruption Highs (STAB-3/4/5) and the shutdown races (STAB-6) all remain. |
+| Performance | **2** | 2 | Unchanged — no batching work landed. Only 2 of 7 drivers batch reads; AbCip's flagship UDT batching is still half-built; deadband exists only in Modbus; the subsystem scales by tag count, not request count. S7's new per-call reconnect attempt marginally worsens dead-device cost (STAB-8). |
+| Conventions | **3** | 3 | Unchanged in substance. The shared seams are real and the analyzer/pragma discipline is a genuine improvement, but the S7 poll-loop fork (now hosting a High defect in its OCE handling), three-tier config-parse strictness, six-way `ReadEnum` copy-paste, logger-registration asymmetry (now hiding the new recovery logs in production), and drifting health idioms all persist. |
+| Underdeveloped areas | **3** | 3 | Slightly improved within the band: the S7 reconnect test gap is closed with both unit and live coverage, TwinCAT replay has fake-client guards. Advertised-but-dead features (FOCAS writes, `UnsMappingTable`, `DisableFC23`, `ConnectionSize`), equipment-tag parser lag, AbLegacy/concurrency test gaps, and the growing unverified-against-hardware set (now including the replay path) keep it at 3. |
 
 ---
 
 ## Recommended remediation order
 
-1. **S7 reconnect** (STAB-1) + a reconnect test template applied to S7 and
-   AbLegacy (UNDER-8) — one field scenario, two drivers, currently fatal.
-2. **TwinCAT native-subscription replay on client recycle** (STAB-2).
+1. **STAB-14 — S7 connect-timeout OCE kills subscription polls** — a
+   two-line filter class fix (`when (cancellationToken.IsCancellationRequested)`
+   on the ensure-wrapper rethrow + the poll-loop catches) plus a
+   fake-factory connect-timeout unit test. This is a regression inside the
+   Critical-3 fix and belongs at the top.
+2. **STAB-15 + STAB-3 together — timeout/framing ⇒ connection-fatal** in
+   both the new S7 classification and the Modbus transport; same defect
+   class, same fix shape (tear down before propagating). Add probe-side
+   `MarkConnectionDeadIfFatal` in S7.
 3. **AbCip per-runtime operation lock** (STAB-4, port from AbLegacy) and
    **FOCAS ConcurrentDictionary caches** (STAB-5) — silent-corruption
    class.
-4. **Modbus transport: timeout/framing-violation ⇒ connection teardown**
-   (STAB-3).
+4. **TwinCAT replay hardening** (STAB-16 Bad-quality surfacing + probe-tick
+   retry; STAB-17 ownership re-check after `SwapHandle`).
 5. **PollGroupEngine v2**: absorb S7's backoff + failure-health, wire
-   `onError` in all five consumers, migrate S7 off its fork
-   (CONV-1, STAB-9, PERF-3, STAB-8).
-6. **Shared strict equipment-tag enum parsing + writable/capability
+   `onError` in all five consumers, migrate S7 off its fork — this also
+   deletes the fork's dangerous OCE handling for good
+   (CONV-1, STAB-9, PERF-3, STAB-6, STAB-8).
+6. **Factory `ILoggerFactory`** (CONV-3) — one line per factory, and now a
+   prerequisite for the new reconnect/replay paths being observable in
+   production at all.
+7. **Shared strict equipment-tag enum parsing + writable/capability
    parity** (CONV-2, UNDER-6) — one Core.Abstractions helper, six deletions.
-7. **Per-protocol read batching** (PERF-1/PERF-2), starting with the AbCip
+8. **Per-protocol read batching** (PERF-1/PERF-2), starting with the AbCip
    planner⇄template-cache join (design already exists in-tree).
-8. Sweep the one-liners: factory `ILoggerFactory` (CONV-3), `ResolveHost`
-   via resolver (CONV-4), FOCAS writable-false (UNDER-1), delete
+9. Sweep the one-liners: `ResolveHost` via resolver (CONV-4, now live via
+   CapabilityInvoker dispatch), FOCAS writable-false (UNDER-1), delete
    `UnsMappingTable` gate (UNDER-2), OpcUaClient alarm-filter encoding
-   (UNDER-3).
+   (UNDER-3), AbLegacy reconnect-test template (UNDER-8).
diff --git a/archreview/06-gateway-integrations.md b/archreview/06-gateway-integrations.md
index 5898bb1e..ab9c6b44 100644
--- a/archreview/06-gateway-integrations.md
+++ b/archreview/06-gateway-integrations.md
@@ -1,21 +1,77 @@
 # Architecture Review 06 — Gateway Integrations (Galaxy Driver + Historian Gateway Driver)
 
-- **Date:** 2026-07-08
-- **Commit:** `9cad9ed0` (master)
+- **Date:** 2026-07-12
+- **Commit:** `f6eaa267` (master, clean tree)
+- **Updates:** the 2026-07-08 review at `9cad9ed0`. Since then the arch-review remediation branches merged to master (none targeted this domain's code), plus two in-domain deltas: the docker-dev `ServerHistorian` stale-keys fix (`7233e2ba`) and the `ZB.MOM.WW.HistorianGateway` Client + Contracts **0.1.0 → 0.2.0** bump (`f6eaa267`). Both `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy*` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway` (and their test projects) are **byte-identical** to `9cad9ed0` — all prior file:line citations remain exact.
 - **Scope:**
   - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy` (+ `.Contracts`, `.Browser`)
   - `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway`
   - Test coverage: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests`, `.Galaxy.Browser.Tests`, `.Historian.Gateway.Tests`
-  - Peripheral (read for context, not reviewed in depth): `Runtime/Historian/*` (recorder, options), `AddressSpaceApplier` provisioning/subscription hooks, `OtOpcUaNodeManager` tie-cluster paging.
+  - Peripheral (read for context, not reviewed in depth): `Runtime/Historian/*` (recorder, options), `AddressSpaceApplier` provisioning/subscription hooks, `OtOpcUaNodeManager` tie-cluster paging, Host `Program.cs` historian wiring, `docker-dev/docker-compose.yml`.
 - **Dimensions:** Stability, Performance, Conventions, Underdeveloped Areas.
 
 ---
 
+## Prior-finding status (9cad9ed0 → f6eaa267)
+
+| ID | One-liner | Status @ f6eaa267 |
+|---|---|---|
+| S-1 (High) | Galaxy write success is optimistic; committed-write failure can never surface | **STILL OPEN** — `GatewayGalaxyDataWriter.cs:281-302` unchanged (empty-statuses ⇒ `Good`; advise-failure path `:234-243` still proceeds). STATUS.md lists "06/S-1 (Galaxy fail-closed)" under *suggested next*, not done. |
+| S-2 (Med) | EventPump saturation drops the newest events (inverted staleness bias) | **STILL OPEN** — `EventPump.cs:128-140` unchanged. |
+| S-3 (Med) | `ReadViaSubscribeOnceAsync` can wait forever on a non-cancellable token | **STILL OPEN** — `GalaxyDriver.cs:738-755` unchanged. |
+| S-4 (Med) | Transport-failure detection is EventPump-only; subscription-less driver never degrades | **STILL OPEN** — `ReportTransportFailure` still has the single pump-fault caller (`GalaxyDriver.cs:929-949`). |
+| S-5 (Med) | Outbox `RemoveAsync` truncates the FIFO prefix; out-of-order acks silently drop | **STILL OPEN** — `FasterLogHistorizationOutbox.cs:158-182` unchanged. |
+| S-6 (Med) | `RefreshConnectionStateAsync` has no production caller; snapshot flags dormant | **STILL OPEN** — verified by grep: only `GatewayHealthSnapshotTests` (3 call sites) invokes it; no hosted-service was added. |
+| S-7 (Low) | Alarm feed reconnect has no backoff (fixed 5 s) | **STILL OPEN** — `GatewayGalaxyAlarmFeed.cs:102-148` unchanged. |
+| S-8 (Low) | `GalaxyMxSession` state unsynchronized across reopen (accepted, emergent safety) | **STILL OPEN** — unchanged; still un-documented invariant. |
+| S-9 / S-10 (Positive) | Store-and-forward discipline; TLS/API-key posture | **Unchanged** — and S-10 strengthened for free by the 0.2.0 client (PEM-bundle trust anchors; see bump assessment). |
+| P-1 (Med) | Galaxy Read = 3 round-trips + publish wait | **STILL OPEN** — gateway-constrained, unchanged. |
+| P-2 (Med) | Alarm drain is one unary `SendEvent` per event | **STILL OPEN** — 0.2.0 ships no batched `SendEvents`; client surface for events unchanged. |
+| P-3 (Low) | Four channels to the historian sidecar (deliberate) | **Unchanged** — trade-off still argued in-source. |
+| P-4 (Positive) | Fan-in/fan-out indexed, bounded, metered | **Unchanged.** |
+| P-5 (Low) | HistoryRead buffers whole replies; `maxEvents <= 0` unbounded | **STILL OPEN** — 0.2.0 `ReadEventsAsync` signature is identical (no wire-side cap param); adapter still drops `maxEvents` (`HistorianGatewayClientAdapter.cs:76-89`). |
+| P-6 (Low) | Outbox peek holds the state lock across disk I/O | **STILL OPEN** — unchanged, still negligible at 64-entry batches. |
+| C-1 / C-2 (Positive) | Secret handling; seam quality | **Unchanged.** |
+| C-3 (Med) | Historian seam leaks wire types by design; keep contained | **STILL OPEN** — `IHistorianGatewayClient.cs` doc unchanged; no guard note added. The 0.2.0 bump is a live demonstration of the risk: contract-semantics changes now flow straight into driver behavior (see U-7). |
+| C-4 (Low) | Options-validation styles diverge | **STILL OPEN** — unchanged; and the warnings-only style's sharp edge materialized live (see S-11). |
+| C-5 (Low) | Retired Wonderware project directories still on disk | **STILL OPEN** — all five dirs (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware{,.Client,.Client.Contracts}` + 2 test dirs) still present, still absent from the slnx. |
+| C-6 (Low) | EventPump silently drops unknown event families with no metric | **STILL OPEN** — `EventPump.cs:192-207` unchanged. |
+| U-1 (High, doc-drift) | CLAUDE.md KNOWN LIMITATION 2 stale (ref feed wired but doc said empty-set) | **FIXED** — the docs branch (`9fadead6`, merged to master via `b67bd9e8`) rewrote LIMITATION 2 to "value-capture wired; live end-to-end verification still pending", matching the code. The remaining *live verification* half was never part of the drift finding — it lives on under U-2. |
+| U-2 (Med) | Live-validation gate built but only partially run | **STILL OPEN** — no live run since; the arch-review #12 rig session ran with the historian **disabled** (that's what surfaced `7233e2ba`). The 0.2.0 bump adds fresh reason to re-run it (see U-7). |
+| U-3 (Med) | WriteSecured / VerifiedWrite user identity stubbed at zero | **STILL OPEN** — `GatewayGalaxyDataWriter.cs:263-264` unchanged. |
+| U-4 (Med) | Dormant-paths inventory (refresh, batched replay, footprint, client-side event cap/filter) | **STILL OPEN** — all four items unchanged; 0.2.0 fixes none of the gateway gaps (no wire-side `maxEvents`, no batched replay/`SendEvents`). |
+| U-5 (Low) | Outbox serializer has no version/format byte | **STILL OPEN** — `HistorizationOutboxEntrySerializer.cs:14-16` unchanged. |
+| U-6 | Test-coverage thin spots (advise-failure status, out-of-order `RemoveAsync`, S-3 hang, pump filter) | **STILL OPEN** — zero test changes in the three gateway test projects since `9cad9ed0`. |
+
+**New findings this pass:** S-11 (Medium), U-7 (Medium), C-7 (Low) — see body.
+
+---
+
+## What changed since 2026-07-08 (in-domain delta assessment)
+
+### The HistorianGateway 0.1.0 → 0.2.0 bump (`f6eaa267`)
+
+One-line change to `Directory.Packages.props`; the consumed contract/client surface changed in five ways (verified by diffing the cached 0.1.0 vs 0.2.0 package XML docs + member lists):
+
+1. **`HistorianTagDefinition.DataType` is now proto3-optional** (`HasDataType`/`ClearDataType` added). Old semantics: wire-0 (`INT1`) was indistinguishable from unset ⇒ the gateway mapped it to the SDK default **Float**. New semantics: presence is explicit, so a set `DataType` — *including* wire-0 `Int1` — is honored. **This directly changes `GatewayTagProvisioner` behavior for Boolean tags** (it maps `Boolean → HistorianDataType.Int1`, `HistorianTypeMapper.cs:28`, and always sets the field, `GatewayTagProvisioner.cs:57`): under 0.1.0 a Boolean tag was silently provisioned as Float; under 0.2.0 it provisions as Int1 (correct). The bump commit's impact analysis covered only the `opc_quality` change — this one was not assessed (see **U-7**).
+2. **`HistorianHistoricalValue.OpcQuality` is now proto3-optional.** The commit's gate check is correct and verified: OtOpcUa never constructs `HistorianHistoricalValue` (no `AddHistoricalValues` caller anywhere in src/tests); all `OpcQuality` uses are the read-side `HistorianSample`/`HistorianAggregateSample`, unaffected.
+3. **`HistorianLiveValue.Quality`/`QualityDetail` documented as dead inputs on `WriteLiveValues`** — the gateway's SQL live path silently discards them (server-managed quality; the SQL login is column-permission-denied). `GatewayHistorianValueWriter.cs:63` populates `Quality` anyway (see **C-7**).
+4. **Client `ToUtcTimestamp` behavior fix:** a `DateTimeKind.Unspecified` timestamp is now interpreted as already-UTC instead of being shifted by the host's UTC offset. Absorbed improvement — OtOpcUa's own conversion sites already `SpecifyKind(..., Utc)` defensively (`GatewayHistorianDataSource.cs:269`, `AlarmEventMapper.cs:20`, `GatewayHistorianValueWriter.cs:70`), and UA-stack DateTimes are Utc-kinded, so no behavior change is expected here; the client-side times passed to `ReadRaw`/`ReadAggregate` are now safe even if an Unspecified kind ever leaks through.
+5. **Client hardening/docs:** `LoadTrustAnchors` now honors a full PEM bundle (root + intermediates) for `CaCertificatePath` pinning (strengthens S-10); the `RawRead`/`RawWrite`/`RawAdmin` escape hatches are documented as bypassing bearer-header injection, typed-exception mapping, and the retry pipeline (the adapter uses only typed methods — unaffected); per-RPC scope requirements (`historian:read` / `historian:tags:write`) are now documented, matching CLAUDE.md's prerequisites; streaming reads documented as *not* retried (transient failures surface — consistent with how `GatewayHistorianDataSource` maps them to health failures); a new `IHistorianGatewayClientTransport` seam exists client-side (unused by OtOpcUa; `IHistorianGatewayClient` remains the repo's seam).
+
+Wire compatibility is preserved (field numbers unchanged; the proto3-optional changes are wire-compatible). No compile-surface break: the solution builds and the gateway-driver suite passed per the bump commit. The residual risk is behavioral, not structural — captured as U-7.
+
+### The docker-dev `ServerHistorian` stale-keys fix (`7233e2ba`)
+
+Config-only: docker-dev compose had `ServerHistorian__Enabled=true` with the retired Wonderware `Host`/`Port`/`SharedSecret` keys, so post-cutover code read an empty `Endpoint`, and `HistorianGatewayClientAdapter.Create` threw an unhandled `UriFormatException` at every bring-up — docker-dev was unbootable against current code. The fix rewrites both centrals to the gateway-shape keys, **disabled by default**, enable via `OTOPCUA_HISTORIAN_ENABLED` + `OTOPCUA_HISTORIAN_ENDPOINT` + env `ServerHistorian__ApiKey`; central-1 verified booting clean. Correct fix for the rig — but it fixed only the config, not the code path that turns a misconfigured section into a crash-loop with a raw `UriFormatException`. That code gap is new finding **S-11**.
+
+---
+
 ## Architecture Overview
 
 ### Galaxy driver data flow (mxaccessgw)
 
-`GalaxyDriver` (`GalaxyDriver.cs`) is a standard Tier-A in-process Equipment-kind driver registered under type name `GalaxyMxGateway`. All Galaxy access flows over gRPC to the external **mxaccessgw** gateway (sibling repo), consumed via the Gitea-feed packages `ZB.MOM.WW.MxGateway.Client` / `.Contracts` (`MxCommand` / `MxEvent` protos). The driver holds **three logical gRPC clients**:
+`GalaxyDriver` (`GalaxyDriver.cs`) is a standard Tier-A in-process Equipment-kind driver registered under type name `GalaxyMxGateway`. All Galaxy access flows over gRPC to the external **mxaccessgw** gateway (sibling repo), consumed via the Gitea-feed packages `ZB.MOM.WW.MxGateway.Client` / `.Contracts` (`MxCommand` / `MxEvent` protos — unchanged this period). The driver holds **three logical gRPC clients**:
 
 1. **Worker session** — `GalaxyMxSession` wraps `MxGatewayClient.OpenSessionAsync` + MXAccess `Register`. All data-plane traffic rides it:
    - **Subscribe** — `GatewayGalaxySubscriber.SubscribeBulkAsync` (session-level `SetBufferedUpdateInterval` applied first, cached last-applied value), events consumed by the shared `EventPump` off the bidirectional `StreamEvents` RPC, fanned out through `SubscriptionRegistry`'s handle→subscription reverse map onto `OnDataChange`.
@@ -26,9 +82,11 @@
 
 Recovery is owned by `ReconnectSupervisor` (Healthy → TransportLost → Reopening → Replaying → Healthy, capped exponential backoff, never gives up): the `EventPump` stream fault feeds `ReportTransportFailure`; reopen routes through `GalaxyMxSession.RecreateAsync` (dispose stale session + client, rebuild) and invalidates the writer's handle/advise caches; replay recreates the EventPump then re-issues SubscribeBulk per tracked subscription and `Rebind`s the registry with the fresh handles. Host connectivity is surfaced through `HostStatusAggregator` (transport state from the supervisor + per-platform `ScanState` probes via `PerPlatformProbeWatcher`).
 
+Since `9cad9ed0`, Galaxy capability dispatch from `DriverInstanceActor` is additionally wrapped by the arch-review #10 `IDriverCapabilityInvoker` seam (tier-A resilience pipeline) — a Runtime-side change outside this domain's code, noted here because Galaxy calls now flow through it.
+
 ### Historian gateway data flow (HistorianGateway sidecar)
 
-`Driver.Historian.Gateway` is the **sole historian backend**, consuming the `ZB.MOM.WW.HistorianGateway.Client` package (`historian_gateway.v1`) behind the proto-typed `IHistorianGatewayClient` seam (`HistorianGatewayClientAdapter` is a pure pass-through; channels are lazy — no I/O at construction). Four independent paths, each with **its own gRPC channel** to the same sidecar (documented deliberate trade-off, `GatewayHistorianServiceCollectionExtensions.cs:57-64`):
+`Driver.Historian.Gateway` is the **sole historian backend**, consuming the `ZB.MOM.WW.HistorianGateway.Client` package **0.2.0** (`historian_gateway.v1`) behind the proto-typed `IHistorianGatewayClient` seam (`HistorianGatewayClientAdapter` is a pure pass-through over the typed client methods; channels are lazy — no I/O at construction, except the eager `new Uri(Endpoint)` at `HistorianGatewayClientAdapter.cs:45`, see S-11). Four independent paths, each with **its own gRPC channel** to the same sidecar (documented deliberate trade-off, `GatewayHistorianServiceCollectionExtensions.cs:57-64`):
 
 1. **Read** — `GatewayHistorianDataSource` (`IHistorianDataSource`): OPC UA HistoryRead Raw/Processed/AtTime/Events → `ReadRaw` / `ReadAggregate` / `ReadAtTime` / `ReadEvents`; at-time replies re-aligned one-snapshot-per-requested-timestamp; health counters under a single lock. Tie-cluster paging (`MaxTieClusterOverfetch`) lives server-side in `OtOpcUaNodeManager` (~line 2200), bound from `ServerHistorianOptions`.
 2. **Alarm history write** — `GatewayAlarmHistorianWriter` (`SendEvent` per event) behind the durable `SqliteStoreAndForwardSink`; maps every outcome (ack, typed client exceptions, raw `RpcException`) to exactly one `Ack` / `RetryPlease` / `PermanentFail` per event and never throws.
@@ -48,7 +106,7 @@ Recovery is owned by `ReconnectSupervisor` (Healthy → TransportLost → Reopen
 - The supervisory-advise failure path (`GatewayGalaxyDataWriter.cs:234-243`) logs a warning, forgets the handle, and **lets the write proceed anyway** — but the file's own comment (`:163-165`) states a raw Write without supervisory advise "doesn't throw (reply looks OK) but the value never reaches the galaxy". That is a silent write loss returning `Good` to the OPC UA client.
 - The server's write-outcome self-correction (#5, reverts the node on a failed device write) can structurally never trigger for Galaxy, so a lost write leaves a phantom-Good node value indefinitely.
 
-**Blast radius:** operator writes (WriteOperate-gated) to Galaxy attributes that silently don't commit, with no Bad status, no health degradation, no metric. Recommendation: (a) return `Uncertain` (not proceed-to-Good) when supervisory advise fails; (b) pursue a gateway-side `WriteComplete` correlation (the gw backlog's `OnWriteComplete` event family already exists in the proto — `EventPump.cs:200-206` filters it out) so the reply/statuses row carries the real commit outcome; (c) add a `galaxy.writes.unconfirmed` counter in the interim.
+**Blast radius:** operator writes (WriteOperate-gated) to Galaxy attributes that silently don't commit, with no Bad status, no health degradation, no metric. Not scheduled in the remediation pass; STATUS.md carries it under "suggested next" as *06/S-1 (Galaxy fail-closed)*. Recommendation: (a) return `Uncertain` (not proceed-to-Good) when supervisory advise fails; (b) pursue a gateway-side `WriteComplete` correlation (the gw backlog's `OnWriteComplete` event family already exists in the proto — `EventPump.cs:200-206` filters it out) so the reply/statuses row carries the real commit outcome; (c) add a `galaxy.writes.unconfirmed` counter in the interim.
 
 #### S-2 (Medium) — EventPump saturation drops the *newest* events (inverted staleness bias)
 
@@ -68,7 +126,7 @@ The read synthesis (`GalaxyDriver.cs:738-755`) fills pending snapshots with `Bad
 
 #### S-6 (Medium) — Historian health snapshot's connection flags are dormant in production
 
-`GatewayHistorianDataSource.RefreshConnectionStateAsync` (`GatewayHistorianDataSource.cs:219-244`) is documented as "intended to be driven by a periodic health hosted-service", but **no production caller exists** — only `GatewayHealthSnapshotTests` invokes it. `ProcessConnectionOpen` / `EventConnectionOpen` in `GetHealthSnapshot` are therefore permanently `false` in a deployed host, which any dashboard consuming the snapshot will read as "historian down" (or, if ignored, the flags are dead weight). Same shape as the memory's "register-AND-pass-into-consumer" trap that bit `GatewayTagProvisioner` in PR #423. Recommendation: add the periodic refresh hosted-service (or fold a refresh into the existing health probe cadence), or remove the flags from the snapshot.
+`GatewayHistorianDataSource.RefreshConnectionStateAsync` (`GatewayHistorianDataSource.cs:219-244`) is documented as "intended to be driven by a periodic health hosted-service", but **no production caller exists** — re-verified this pass: only `GatewayHealthSnapshotTests` invokes it. `ProcessConnectionOpen` / `EventConnectionOpen` in `GetHealthSnapshot` are therefore permanently `false` in a deployed host, which any dashboard consuming the snapshot will read as "historian down" (or, if ignored, the flags are dead weight). Same shape as the memory's "register-AND-pass-into-consumer" trap that bit `GatewayTagProvisioner` in PR #423. Recommendation: add the periodic refresh hosted-service (or fold a refresh into the existing health probe cadence), or remove the flags from the snapshot.
 
 #### S-7 (Low) — Alarm feed reconnect has no backoff
 
@@ -78,6 +136,14 @@ The read synthesis (`GalaxyDriver.cs:738-755`) fills pending snapshots with `Bad
 
 `_session` / `_connected` (`GalaxyMxSession.cs:28-32`) are plain fields; `RecreateAsync` tears down while concurrent writers/subscribers may hold or fetch `Session`. In practice the supervisor's single-flight recovery plus the per-call try/catch (mapped to `BadCommunicationError`) contain it, and the caches are invalidated post-reopen — but the safety is emergent, not designed. Acceptable; document the invariant on `Session`.
 
+#### S-11 (Medium, NEW) — An enabled-but-misconfigured `ServerHistorian` section crash-loops the Host with a raw `UriFormatException`; one corner gets no warning at all
+
+`HistorianGatewayClientAdapter.Create` eagerly does `new Uri(options.Endpoint)` (`HistorianGatewayClientAdapter.cs:45`). `ServerHistorianOptions.Validate()` produces exactly the right warning for an empty Endpoint (`ServerHistorianOptions.cs:78-79`) — but it is **warnings-only**: registration proceeds (`Runtime/ServiceCollectionExtensions.cs:135-139`, `Host/Program.cs:123-127`) and the first DI resolution throws an unhandled `UriFormatException`, crashing the Host at bring-up. This is not theoretical — it made docker-dev unbootable until the config-side fix `7233e2ba` (whose commit message documents the exact failure). The code path is untouched.
+
+The sharper corner: **`AddAlarmHistorian` gates only on `AlarmHistorian:Enabled`** (`Runtime/ServiceCollectionExtensions.cs:87`) but sources its connection from the `ServerHistorian` section (`Program.cs:127`). With `AlarmHistorian:Enabled=true` and `ServerHistorian:Enabled=false` (Endpoint legitimately empty), `Validate()` early-returns on `!Enabled` (`ServerHistorianOptions.cs:77`) — so the operator gets **zero warning**, then the same `UriFormatException` out of `CreateAlarmWriter` when the sink singleton is built. Same exposure for the continuous-historization writer (`Program.cs:187`, though that one is co-gated on `ServerHistorian.Enabled`, so only a *malformed* non-empty Endpoint reaches it).
+
+A hard fail at startup on misconfiguration is defensible — but it should be a deliberate, named failure, not an incidental `UriFormatException` from deep inside a DI factory. Recommendation: (a) validate `Endpoint` parses as an absolute URI in `Validate()`; (b) when the section is Enabled (or *consumed* by an enabled AlarmHistorian) and Endpoint is empty/invalid, fail fast with an `InvalidOperationException` naming the configuration key — or force-disable and keep the Null defaults; (c) make `AddAlarmHistorian` also run `ServerHistorianOptions.Validate()`-equivalent checks on the connection section it actually depends on.
+
 #### S-9 (Positive) — Store-and-forward + outbox crash-safety discipline is strong
 
 - `GatewayAlarmHistorianWriter` (`GatewayAlarmHistorianWriter.cs:66-118`) short-circuits remaining batch entries to `RetryPlease` on shutdown, classifies auth failures as retryable (an auth blip never dead-letters), and defaults unknowns to `PermanentFail` so poison events cannot loop.
@@ -86,7 +152,7 @@ The read synthesis (`GalaxyDriver.cs:738-755`) fills pending snapshots with `Bad
 
 #### S-10 (Positive) — TLS / API-key posture
 
-Both integrations support TLS with CA pinning (`CaCertificatePath`), the historian's `AllowUntrustedServerCertificate` inversion is explicitly documented at the mapping site (`HistorianGatewayClientAdapter.cs:49-52`), and keys are never logged. See C-1 for the resolver.
+Both integrations support TLS with CA pinning (`CaCertificatePath`), the historian's `AllowUntrustedServerCertificate` inversion is explicitly documented at the mapping site (`HistorianGatewayClientAdapter.cs:49-52`), and keys are never logged. The 0.2.0 client strengthens this for free: `CaCertificatePath` now honours a full PEM bundle (root + intermediates) as trust anchors, and the raw-client escape hatches are documented as bypassing bearer injection (the adapter uses only typed methods, so no exposure). See C-1 for the resolver.
 
 ### 2. Performance
 
@@ -96,7 +162,7 @@ Both integrations support TLS with CA pinning (`CaCertificatePath`), the histori
 
 #### P-2 (Medium) — Alarm-history drain is one unary `SendEvent` per event
 
-`GatewayAlarmHistorianWriter.WriteBatchAsync` (`GatewayAlarmHistorianWriter.cs:66-80`) serializes the batch — deliberate poison-event isolation, but an alarm storm of N events costs N sequential RPC round-trips out of the SQLite drain worker. If the gateway ever grows a batched `SendEvents` with per-event status rows, adopt it; until then this is an accepted, well-documented ceiling.
+`GatewayAlarmHistorianWriter.WriteBatchAsync` (`GatewayAlarmHistorianWriter.cs:66-80`) serializes the batch — deliberate poison-event isolation, but an alarm storm of N events costs N sequential RPC round-trips out of the SQLite drain worker. The 0.2.0 client still has no batched `SendEvents` with per-event status rows; when the gateway grows one, adopt it. Until then this is an accepted, well-documented ceiling.
 
 #### P-3 (Low) — Four channels to the historian sidecar; two-plus to mxaccessgw
 
@@ -110,7 +176,7 @@ Each historian path owns its channel (read / alarm-write / provisioner / value-w
 
 #### P-5 (Low) — HistoryRead buffers whole replies; `maxEvents <= 0` is unbounded
 
-`ReadRawAsync` / `ReadProcessedAsync` / `ReadEventsAsync` (`GatewayHistorianDataSource.cs:59-182`) drain the stream into a list before mapping. Raw reads are capped by `maxValuesPerNode`, but an events read with `maxEvents <= 0` collects without limit (the gateway's `RuntimeDb:EventReadMaxRows` is the only cap, and it's a remote deployment knob). The tie-cluster paging bound (`MaxTieClusterOverfetch`, validated > 0 in `ServerHistorianOptions.Validate`, enforced at `OtOpcUaNodeManager.cs:2200-2214`) is well-designed. Recommendation: clamp events reads to a server-side default cap when the caller passes 0.
+`ReadRawAsync` / `ReadProcessedAsync` / `ReadEventsAsync` (`GatewayHistorianDataSource.cs:59-182`) drain the stream into a list before mapping. Raw reads are capped by `maxValuesPerNode`, but an events read with `maxEvents <= 0` collects without limit (the gateway's `RuntimeDb:EventReadMaxRows` is the only cap, and it's a remote deployment knob). Re-checked against 0.2.0: `ReadEventsAsync`'s client signature is unchanged — still no wire-side cap, so the adapter still cannot forward `maxEvents` (`HistorianGatewayClientAdapter.cs:76-89`). The tie-cluster paging bound (`MaxTieClusterOverfetch`, validated > 0 in `ServerHistorianOptions.Validate`, enforced at `OtOpcUaNodeManager.cs:2200-2214`) is well-designed. Recommendation: clamp events reads to a server-side default cap when the caller passes 0.
 
 #### P-6 (Low) — Outbox peek holds the state lock across disk I/O
 
@@ -120,39 +186,39 @@ Each historian path owns its channel (read / alarm-write / provisioner / value-w
 
 #### C-1 (Positive) — Secret handling is a model for the other drivers
 
-`GalaxySecretRef` (`Galaxy.Contracts/GalaxySecretRef.cs`) resolves `env:` / `file:` / `dev:` / literal with fail-fast on unset env vars and a startup warning on unprefixed cleartext; it lives in Contracts so the runtime driver and the AdminUI browser share one implementation (`GalaxyDriverBrowser.cs:125`). The historian side takes `ServerHistorian__ApiKey` via env with a "never commit" doc contract and an empty-key `Validate()` warning. No key value is ever logged on either path.
+`GalaxySecretRef` (`Galaxy.Contracts/GalaxySecretRef.cs`) resolves `env:` / `file:` / `dev:` / literal with fail-fast on unset env vars and a startup warning on unprefixed cleartext; it lives in Contracts so the runtime driver and the AdminUI browser share one implementation (`GalaxyDriverBrowser.cs:125`). The historian side takes `ServerHistorian__ApiKey` via env with a "never commit" doc contract and an empty-key `Validate()` warning; the docker-dev rewrite (`7233e2ba`) follows the same discipline (`${ServerHistorian__ApiKey:-}` pass-through, key never committed). No key value is ever logged on either path.
 
 #### C-2 (Positive) — Seam quality and driver-family consistency
 
-- `IHistorianGatewayClient` is a clean single seam: every consumer (`GatewayHistorianDataSource`, `GatewayAlarmHistorianWriter`, `GatewayTagProvisioner`, `GatewayHistorianValueWriter`) depends only on it; `FakeHistorianGatewayClient` backs the offline suite; the adapter is a zero-translation pass-through.
+- `IHistorianGatewayClient` is a clean single seam: every consumer (`GatewayHistorianDataSource`, `GatewayAlarmHistorianWriter`, `GatewayTagProvisioner`, `GatewayHistorianValueWriter`) depends only on it; `FakeHistorianGatewayClient` backs the offline suite; the adapter is a zero-translation pass-through. (The 0.2.0 client's new `IHistorianGatewayClientTransport` seam is package-internal plumbing — correctly not adopted; the repo seam stays `IHistorianGatewayClient`.)
 - The Galaxy capability seams (`IGalaxyHierarchySource`, `IGalaxyDataReader/Writer`, `IGalaxySubscriber`, `IGalaxyAlarmFeed/Acknowledger`) plus the internal test ctor mirror the protocol-driver pattern; tracing decorators (`Traced*`) wrap the production seams without an OpenTelemetry dependency.
 - `GalaxyDriverFactoryExtensions.CreateInstance` throws precise, instance-named errors on missing required fields (`GalaxyDriverFactoryExtensions.cs:56-75`), and `GalaxyDriverProbe` uses `JsonStringEnumConverter` (the FB-9/FB-10 enum-serialization lesson applied).
 
 #### C-3 (Medium) — The historian seam leaks wire types by design; keep it contained
 
-`IHistorianGatewayClient` signatures use `HistorianGateway.Contracts.Grpc` types (`HistorianSample`, `RetrievalMode`, `WriteAck`, ...) — documented as deliberate (`IHistorianGatewayClient.cs:5-11`). This is fine while the driver is the only consumer, but the pure `Mapping/` layer is the real anti-corruption boundary: any future consumer must sit **above** `GatewayHistorianDataSource`, never on the seam. Worth a one-line guard note in the interface doc.
+`IHistorianGatewayClient` signatures use `HistorianGateway.Contracts.Grpc` types (`HistorianSample`, `RetrievalMode`, `WriteAck`, ...) — documented as deliberate (`IHistorianGatewayClient.cs:5-11`). This is fine while the driver is the only consumer, but the pure `Mapping/` layer is the real anti-corruption boundary: any future consumer must sit **above** `GatewayHistorianDataSource`, never on the seam. The 0.2.0 bump demonstrated the flip side concretely: a *semantics* change in a contract type (`HistorianTagDefinition.DataType` presence, U-7) flowed straight into driver behavior with no compile signal. Worth a one-line guard note in the interface doc, plus a bump-checklist habit of diffing the contract XML (this pass's method) rather than relying on green builds.
 
 #### C-4 (Low) — Options-validation styles diverge
 
-Galaxy: throw-on-construct for required fields; DataAnnotations attributes on the records are decorative (nothing runs `Validator`). Historian: `Validate()` returns operator warnings and never throws (registration logs them). Both are defensible, but the repo now has two idioms for "driver-adjacent options validation". Pick one for new sections (the warnings-list style is the friendlier operational contract).
+Galaxy: throw-on-construct for required fields; DataAnnotations attributes on the records are decorative (nothing runs `Validator`). Historian: `Validate()` returns operator warnings and never throws (registration logs them). Both are defensible, but the repo now has two idioms for "driver-adjacent options validation" — and the warnings-only historian style's sharp edge materialized live this period (the docker-dev crash, see S-11): the operator got the correct warning immediately followed by an opaque crash. Pick one idiom for new sections, and give the warnings style a fail-fast tier for configurations that will provably crash anyway.
 
 #### C-5 (Low) — Retired Wonderware project directories still on disk
 
-`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware{,.Client,.Client.Contracts}` (and their test dirs) remain in the tree but are absent from `ZB.MOM.WW.OtOpcUa.slnx` (0 references). Dead directories invite grep noise and accidental resurrection; delete them (git history preserves the code).
+`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware{,.Client,.Client.Contracts}` (and their test dirs) remain in the tree but are absent from `ZB.MOM.WW.OtOpcUa.slnx` (0 references) — re-verified this pass. Dead directories invite grep noise and accidental resurrection; delete them (git history preserves the code).
 
 #### C-6 (Low) — EventPump silently drops unknown event families with no metric
 
 `Dispatch` (`EventPump.cs:192-207`) returns without counting on any non-`OnDataChange` family. The rationale comment is good, but a gateway version emitting `OnBufferedDataChange` (or a new family) would silently discard data with zero observability — the alarm feed counts its decode drops (`AlarmTransitionsDecodingFailures`); the pump should count filtered/unknown families the same way.
 
+#### C-7 (Low, NEW) — The live-value writer populates a documented-dead `Quality` field
+
+`GatewayHistorianValueWriter` maps `HistorizationValue.Quality` onto `HistorianLiveValue.Quality` (`GatewayHistorianValueWriter.cs:63`), but the 0.2.0 contract now documents that field (and `QualityDetail`) as **silently discarded on the `WriteLiveValues` SQL path** — the gateway's SQL login is column-permission-denied on the server-managed Quality column, so the value is accepted on the wire and dropped. Harmless today because the recorder hardcodes `GoodQuality = 192` for every captured value (`ContinuousHistorizationRecorder.cs:45,256` — the outbox round-trips it, `:419`), so nothing meaningful is lost. The hazard is forward-looking: if the recorder is ever extended to capture *real* node quality (a natural next step), it will appear to work and silently persist nothing — quality will remain server-stamped. Add a comment at the mapping site citing the 0.2.0 contract note (Contracts C-002), or stop populating the field so the dead input is visible.
+
 ### 4. Underdeveloped Areas
 
-#### U-1 (High, documentation-drift) — CLAUDE.md KNOWN LIMITATION 2 is stale: the historized-ref feed is now wired
+#### U-2 (Medium) — KNOWN LIMITATION 1: the live-validation gate is built but still not fully run
 
-CLAUDE.md states the recorder is "spawned with an EMPTY historized-ref set … registers interest in nothing and historizes nothing" pending a `SetHistorizedRefs`-style feed. The code has closed that gap: `WithOtOpcUaRuntimeActors` still spawns with `historizedRefs: Array.Empty()` (`Runtime/ServiceCollectionExtensions.cs:272`) **but** wraps the recorder in `ActorHistorizedTagSubscriptionSink` and hands it to the applier (`:287-307`), `AddressSpaceApplier` pushes the per-deploy add/remove delta (`AddressSpaceApplier.cs:343`), and the recorder handles `UpdateHistorizedRefs` (`ContinuousHistorizationRecorder.cs:186`) — the in-source comment explicitly says this "clos[es] the T18 ref-feed gap". Actions: (1) update CLAUDE.md; (2) the end-to-end value-capture path (deploy → delta → mux tap → outbox → `WriteLiveValues`) has, as far as the tree shows, **never been live-verified** — given this repo's repeated "wired-but-inert in prod" history (F10b `DeferredAddressSpaceSink`, PR #423 provisioner), a live `/run` against a real gateway is the required close-out.
-
-#### U-2 (Medium) — KNOWN LIMITATION 1: the live-validation gate is built but only partially run
-
-The `Category=LiveIntegration` suite is complete and skip-clean: `GatewayLiveFixture` env-gates on `HISTGW_GATEWAY_ENDPOINT`/`APIKEY` (+ per-test `HISTGW_TEST_TAG`, `HISTGW_WRITE_SANDBOX_TAG`, `HISTGW_ALARM_SOURCE`) with a bounded 3 s TCP probe so a down VPN skips instead of hanging (`Live/GatewayLiveFixture.cs`). Four live tests cover read, EnsureTags+write, alarm SendEvent→ReadEvents round-trip, and a SendEvent contract check. Recent history (merge `245316d8`, "assert FU-1 alarm SendEvent→ReadEvents round-trip (gateway C4 fixed)") shows the alarm leg has been run live at least once, but CLAUDE.md still flags the whole cutover as unvalidated. A full documented run of the suite (and a CLAUDE.md status update) is the remaining work — the infrastructure is not the gap.
+The `Category=LiveIntegration` suite is complete and skip-clean: `GatewayLiveFixture` env-gates on `HISTGW_GATEWAY_ENDPOINT`/`APIKEY` (+ per-test `HISTGW_TEST_TAG`, `HISTGW_WRITE_SANDBOX_TAG`, `HISTGW_ALARM_SOURCE`) with a bounded 3 s TCP probe so a down VPN skips instead of hanging (`Live/GatewayLiveFixture.cs`). Four live tests cover read, EnsureTags+write, alarm SendEvent→ReadEvents round-trip, and a SendEvent contract check. The alarm leg has been run live at least once (merge `245316d8`); nothing further has run since the prior review — the arch-review #12 rig session ran with the historian **disabled**. The 0.2.0 bump raises the stakes: the suite has never executed against a 0.2.0 gateway with the 0.2.0 client, and the DataType presence change (U-7) is precisely the kind of semantics shift only the live EnsureTags leg can validate. A full documented run of the suite is the remaining work — the infrastructure is not the gap.
 
 #### U-3 (Medium) — WriteSecured / VerifiedWrite user identity is stubbed at zero
 
@@ -163,34 +229,47 @@ The `Category=LiveIntegration` suite is complete and skip-clean: `GatewayLiveFix
 - `GatewayHistorianDataSource.RefreshConnectionStateAsync` — no production caller (see S-6).
 - Replay still fans out per-subscription `SubscribeBulk`; the gateway's batched `ReplaySubscriptionsCommand` remains a "PR 6.x can swap this" note (`GalaxyDriver.cs:314-316`).
 - `GalaxyDriver.FlushOptionalCachesAsync` is a no-op and `GetMemoryFootprint` is a constants-based estimate (`GalaxyDriver.cs:561-575`) — fine, but the server's cache-flush heuristic gets synthetic data from this driver.
-- `HistorianGatewayClientAdapter.ReadEventsAsync` never forwards `maxEvents` on the wire (client-side cap only, documented at `IHistorianGatewayClient.cs:58-63`) and the source filter is re-applied client-side defensively (`GatewayHistorianDataSource.cs:152-157`) because the gateway-side filter "may not be present" — both are polite workarounds for gateway gaps that should be tracked against the sidecar repo.
+- `HistorianGatewayClientAdapter.ReadEventsAsync` never forwards `maxEvents` on the wire (client-side cap only, documented at `IHistorianGatewayClient.cs:58-63`) and the source filter is re-applied client-side defensively (`GatewayHistorianDataSource.cs:152-157`) because the gateway-side filter "may not be present" — re-checked against 0.2.0: neither gap is addressed (the `ReadEventsAsync` client signature is unchanged). Both remain polite workarounds for gateway gaps that should be tracked against the sidecar repo.
 
 #### U-5 (Low) — Outbox serializer has no version/format byte
 
 `HistorizationOutboxEntrySerializer` writes a fixed positional layout with no version prefix (`HistorizationOutboxEntrySerializer.cs:14-16`). Any future field addition breaks recovery of pre-existing on-disk entries with an undiagnosable deserialization failure mid-`RecoverState`. One reserved byte now is free; a migration later is not.
 
+#### U-7 (Medium, NEW) — The 0.2.0 bump's impact analysis missed the `DataType` presence change that alters Boolean tag provisioning
+
+The bump commit (`f6eaa267`) gates only on the M3 `opc_quality` change ("checked and CLEAR" — correct, verified: OtOpcUa never writes `HistorianHistoricalValue`). But 0.2.0 also made **`HistorianTagDefinition.DataType` proto3-optional** (contracts review M-2), and that one lands squarely on `GatewayTagProvisioner`:
+
+- The provisioner always sets `DataType` explicitly (`GatewayTagProvisioner.cs:57`), and `Boolean → HistorianDataType.Int1` (`HistorianTypeMapper.cs:28`) — where **Int1 is wire value 0**.
+- Under 0.1.0 the gateway could not distinguish wire-0 from unset and mapped it to the SDK default **Float** — i.e. every Boolean tag EnsureTags'd to date was silently provisioned as a Float historian tag.
+- Under 0.2.0 presence is explicit, so the same call now provisions **Int1** — the intended behavior, arriving as a silent semantic change with zero code diff on our side.
+
+Two consequences need closing out: (1) any Boolean tags provisioned under 0.1.0 exist in the historian as Float, and what the gateway's `EnsureTags` does when the existing tag's type mismatches the (now-honored) requested type is unverified — per-tag failure outcomes would surface only in the provisioner's tally log (`failed=N`); (2) the live EnsureTags gate (U-2) has never run under 0.2.0 semantics. Recommendation: re-run the `Category=LiveIntegration` EnsureTags leg including a Boolean tag; check historian-side types for any previously provisioned Boolean tags; and adopt "diff the contract XML between package versions" (this review's method — both versions sit in the local NuGet cache) as the standing bump-gate checklist, since green builds cannot catch presence-semantics changes.
+
 #### U-6 — Test coverage assessment
 
+Zero test changes in the three gateway test projects since `9cad9ed0`; the assessment stands verbatim:
+
 - **Galaxy** (~250 test methods across 34 files): strong unit coverage of the hard parts — reconnect orchestration (`GalaxyMxSessionReconnectTests`, `ReconnectSupervisorTests`), pump fault/bounded-channel behaviour, registry rebind/handle-resolve, writer caches, alarm feed decode, value encode/decode, status mapping, probe, factory. Three skip-gated live smokes (`GatewayGalaxyLiveReopenAndWriteTests` — proven 2/2 against `10.100.0.48:5120` per project memory — and `GatewayGalaxyAlarmFeedLiveTests`) gated on `MXGW_ENDPOINT` + `GALAXY_MXGW_API_KEY`.
 - **Historian.Gateway** (~15 files): mappers fully covered, writer/provisioner/data-source outcome classification covered via `FakeHistorianGatewayClient`, outbox recovery/capacity covered, plus the 4-test live suite.
-- **Thin spots:** no test drives `GalaxyDriver` write-through the supervisory-advise *failure* branch asserting the returned status (S-1's blast radius is untested); no test exercises out-of-order `RemoveAsync` on the outbox (S-5); nothing covers the read-hang case in S-3; `EventPump` unknown-family filtering has no assertion (C-6).
+- **Thin spots:** no test drives `GalaxyDriver` write-through the supervisory-advise *failure* branch asserting the returned status (S-1's blast radius is untested); no test exercises out-of-order `RemoveAsync` on the outbox (S-5); nothing covers the read-hang case in S-3; `EventPump` unknown-family filtering has no assertion (C-6). New this pass: nothing asserts the misconfigured-Enabled crash shape (S-11), and no test pins the Boolean→Int1 provisioning expectation against a 0.2.0-semantics fake (U-7).
 
 ---
 
 ## Maturity Ratings
 
-| Dimension | Rating (1-5) | Justification |
-|---|---|---|
-| Stability | **4** | Layered recovery (supervisor, self-reconnecting feeds, backoff, partial-open teardown) and disciplined never-throw write sinks; docked for the Galaxy silent-write-loss blast radius (S-1), EventPump-only fault detection (S-4), and the dormant health refresh (S-6). |
-| Performance | **4** | Bounded channels with drop metering, O(1) fan-out indexes, handle borrowing, and a bounded tie-cluster over-fetch; docked for the 3-round-trip read synthesis (P-1) and serial alarm sends (P-2), both documented and gateway-constrained. |
-| Conventions | **4** | Exemplary seams, shared secret resolver, consistent driver-family shape and tracing decorators; docked for the dual options-validation idiom, on-disk retired Wonderware dirs, and the unmetered pump filter. |
-| Underdeveloped areas | **3** | Live-validation infrastructure is complete but the gate is only partially run and CLAUDE.md's limitation 2 is stale against code that has closed the gap; WriteSecured identity is stubbed; several polite client-side workarounds paper over gateway gaps. |
+| Dimension | Rating (1-5) | Prior | Justification |
+|---|---|---|---|
+| Stability | **4** | 4 | Layered recovery (supervisor, self-reconnecting feeds, backoff, partial-open teardown) and disciplined never-throw write sinks are unchanged; still docked for the Galaxy silent-write-loss blast radius (S-1 — explicitly deferred by the remediation pass), EventPump-only fault detection (S-4), the dormant health refresh (S-6), and now the proven-live misconfiguration crash-loop (S-11, fixed only config-side). |
+| Performance | **4** | 4 | No in-domain code change; bounded channels with drop metering, O(1) fan-out indexes, handle borrowing, and a bounded tie-cluster over-fetch stand; still docked for the 3-round-trip read synthesis (P-1) and serial alarm sends (P-2), both documented and still gateway-constrained in 0.2.0. |
+| Conventions | **4** | 4 | Exemplary seams, shared secret resolver, consistent driver-family shape and tracing decorators; still docked for the dual options-validation idiom (whose sharp edge now has a live incident attached), the on-disk retired Wonderware dirs, the unmetered pump filter, and the new dead-Quality-input mapping (C-7). |
+| Underdeveloped areas | **3** | 3 | The U-1 doc drift is fixed (the one prior finding closed this period), but the live gates net widened: the historian live suite has still never fully run, and the 0.2.0 bump added unverified semantics on the provisioning path (U-7) that only the live EnsureTags leg can retire. WriteSecured identity remains stubbed; the gateway-gap workarounds remain. |
 
 ---
 
 ## Cross-Cutting Themes
 
-1. **"Wired but never invoked" is this codebase's recurring failure mode** — the provisioner (PR #423), the F10b sink forwarding, and now `RefreshConnectionStateAsync` (S-6). Any new capability interface or hook needs an explicit production-caller check plus a live `/run`, not just unit tests.
-2. **Optimistic-Good on fire-and-forget writes** — Galaxy structurally cannot report a failed commit (S-1); reviewers of the node-write router / write-outcome self-correction should not assume driver parity here.
-3. **CLAUDE.md drift** — KNOWN LIMITATION 2 is closed in code (U-1) and LIMITATION 1 is partially exercised (U-2); the doc should be reconciled before it misleads the next session.
-4. **Gateway-gap workarounds accrue client-side** — defensive source filters, client-side event caps, missing batched replay/SendEvents; these belong on the sister repos' backlogs with links from the in-source comments.
+1. **"Wired but never invoked" is this codebase's recurring failure mode** — the provisioner (PR #423), the F10b sink forwarding, and `RefreshConnectionStateAsync` (S-6, still open). Any new capability interface or hook needs an explicit production-caller check plus a live `/run`, not just unit tests. (The arch-review pass built the reflection forwarding guard for the sink family; the health-refresh instance in this domain remains unguarded.)
+2. **Optimistic-Good on fire-and-forget writes** — Galaxy structurally cannot report a failed commit (S-1, deferred by the remediation pass); reviewers of the node-write router / write-outcome self-correction should not assume driver parity here.
+3. **Green builds cannot gate contract-semantics changes** — the 0.2.0 bump was wire-compatible and compile-clean yet silently changed Boolean tag provisioning (U-7) and dead-lettered the live-value Quality field (C-7). The bump gate for gateway packages should include a contract-XML diff (both versions are always in the local NuGet cache) and a live-suite run, not just `dotnet test`.
+4. **Warnings-only validation defers the crash, it doesn't prevent it** — the docker-dev incident (S-11) logged the exact right warning milliseconds before an opaque `UriFormatException` crash-loop. Configurations that will provably fail should fail fast, by name.
+5. **Gateway-gap workarounds accrue client-side** — defensive source filters, client-side event caps, missing batched replay/`SendEvents`; 0.2.0 closed none of them. These belong on the sister repos' backlogs with links from the in-source comments.
diff --git a/archreview/07-client-tooling-engineering.md b/archreview/07-client-tooling-engineering.md
index c550ad54..3bd99eb8 100644
--- a/archreview/07-client-tooling-engineering.md
+++ b/archreview/07-client-tooling-engineering.md
@@ -2,94 +2,121 @@
 
 | | |
 |---|---|
-| **Date** | 2026-07-08 |
-| **Commit** | `9cad9ed0` (master) |
-| **Reviewer** | Architecture review agent (deep-review sweep, slice 07) |
+| **Date** | 2026-07-12 |
+| **Commit** | `f6eaa267` (master, clean tree) |
+| **Reviewer** | Architecture review agent (deep-review sweep, slice 07 — re-run) |
+| **Supersedes** | The 2026-07-08 review of this domain at `9cad9ed0`. All arch-review remediation branches have since merged to master (~40 commits), including the three squarely in this slice: `f0082af5` (07/C-1 analyzer wiring + triage), `10b89830` (07/S-1,S-2,S-4 CI overhaul), and `bacea1a4` (#10 dispatch-gap closure, which removed the C-1 triage pragmas). Also landed: the `/fixdocs` XML-doc sweep `3b5ef439` and the HistorianGateway client 0.1.0→0.2.0 bump `f6eaa267`. |
 | **Scope** | `src/Client/*` (Client.CLI, Client.Shared, Client.UI) + `tests/Client/*`; `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers` + `tests/Tooling/*`; cross-cutting: `Directory.Build.props`, `Directory.Packages.props`, `NuGet.config`, `ZB.MOM.WW.OtOpcUa.slnx`, `.github/workflows/`, `ci/`, `scripts/`, `docker-dev/`, overall test architecture, docs freshness, repo-root hygiene |
 
 ---
 
+## Prior-finding status (9cad9ed0 → f6eaa267)
+
+Verified against actual code/tree state, not remediation claims. `src/Client` production code is
+**byte-identical** to the prior baseline (zero commits touched it), so every client-code finding
+carries over verbatim.
+
+| ID | One-liner (prior) | Verified status |
+|---|---|---|
+| S-1 | CI gated ~15% of the test matrix (5-project hand-maintained matrix) | **FIXED** — `v2-ci.yml:58-62` runs one whole-solution leg `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "Category!=E2E&Category!=LiveIntegration"` (`10b89830`, merged `83fdf5fa`). Self-maintaining; all 47 test csprojs in the slnx are covered, including Client (388) + Analyzers (32). Residual: the first actual CI run of this pipeline post-push was not observable from this review — YAML is statically well-formed; the mixed xunit v3/2.9.2 whole-solution run was verified locally per the commit, not on a runner. |
+| S-2 | Green CI == skipped integration tests, indistinguishable from pass | **PARTIALLY FIXED** — new `scripts/ci/assert-not-all-skipped.sh` gate: strict (`MIN_EXECUTED=1`) on the unit leg (`v2-ci.yml:66-68`), report-only (`MIN_EXECUTED=0`) on the fixtureless integration leg (`v2-ci.yml:102-106`) with a documented follow-up to run opc-plc as a workflow `services:` and flip it strict. All-skipped can no longer masquerade as green on the unit leg; the integration tier is still green-while-skipped by explicit, commented design. See new S-9 (threshold coarseness) and P-5 (the leg is now redundant). |
+| S-3 | Nightly E2E workflow is a permanent green no-op | **STILL OPEN** — `v2-e2e.yml` unchanged since baseline (`git diff` empty); zero tests carry `Category=E2E` (grep re-verified). |
+| S-4 | Fixed-sleep timing tests are the dominant wait style | **PARTIALLY FIXED** — the 11 Client.CLI.Tests startup sleeps were replaced with `SubscribeInvoked`/`SubscribeAlarmsInvoked` `TaskCompletionSource` readiness signals (`FakeOpcUaClientService.cs:57,60`, `10b89830`); the one remaining `Task.Delay(25)` (`SubscribeCommandSummaryTests.cs:262`) is a bounded poll-until-predicate helper, not a fixed startup sleep — fine. Driver-suite fixed sleeps remain (see body) and are **now CI-exercised** by S-1's fix, so their flake risk is live. |
+| S-5 | Client.Shared subscription lock-discipline gaps | **STILL OPEN** — `OpcUaClientService.cs:262` (unguarded null-check/create), `:695-696` (failover nulls outside the lock), `UnsubscribeAlarmsAsync` not routed through the semaphore. File untouched since baseline. |
+| S-6 | Global `Log.Logger` swap per CLI command | **STILL OPEN** — `CommandBase.cs:123,133` unchanged. |
+| S-7 | No `global.json`, but v2-ci.yml claims one pins the SDK | **STILL OPEN** — no `global.json` at root; notably the false claim **survived the workflow rewrite** (`v2-ci.yml:10-12` still says "pinned via global.json"). The Roslyn 5.0.0 pin rationale in `Directory.Packages.props` still depends on an exact SDK band. |
+| S-8 | First-caller interval wins for the shared data subscription | **STILL OPEN** — `OpcUaClientService.cs:262`. |
+| P-1 | 7 uncached restore+build passes per CI push | **PARTIALLY FIXED** — matrix collapse reduced it to 4 passes (build + unit + 2 integration legs); still no `actions/cache` for NuGet, and the `build` job's output is still discarded (every downstream job restores + rebuilds). |
+| P-2 | Recursive browse is N+1 over `HasChildrenAsync` | **STILL OPEN** — `OpcUaClientService.cs:230-231`, `SubscribeCommand.cs` serial walk; unchanged. |
+| P-3 | (positive) probe-once integration-fixture pattern | **STILL VALID** — unchanged; now doubly relevant since these fixtures probe (and skip) inside the CI unit leg. |
+| P-4 | Unbounded channel in CLI subscribe output | **STILL OPEN** (cosmetic) — unchanged. |
+| C-1 | Custom analyzer wired into **zero** projects | **FIXED** — `Directory.Build.props:45-51` injects the analyzer as `OutputItemType="Analyzer"` into every project except the analyzer + its tests (`f0082af5`). Triage verified scoped and honest: 12 wire-level test/CLI csprojs carry commented `NoWarn` (6 driver CLIs, AbCip/S7 unit+integration suites, ScriptedAlarms/VirtualTags tests); 3 driver-internal-self-call pragmas + 1 test-only-scaffolding pragma (`GenericDriverNodeManager.cs:71`) remain, each with rationale. The 6 RESILIENCE-DISPATCH-GAP pragmas the wiring surfaced were **removed** by the follow-on `bacea1a4`, which actually wired `CapabilityInvoker` into dispatch via a Polly-free `IDriverCapabilityInvoker` seam — grep for `RESILIENCE-DISPATCH-GAP` now matches only the Runtime guard test. Negative control (unwrap → build error in Runtime) documented in both commits. Residual enforcement caveat: see new C-7. |
+| C-2 | TWE never reached Client/Tooling src (or most tests) | **STILL OPEN** — zero `TreatWarningsAsErrors` in `src/Client/*/*.csproj` or `src/Tooling/*/*.csproj`; still 9/47 test projects opted in. Now also weakens C-1's enforcement (C-7). |
+| C-3 | (positive) CPM discipline + valid audit carve-out | **STILL VALID** — `NuGetAuditSuppress` for GHSA-2m69-gcr7-jv3q still present and still accurate (`Directory.Build.props:19-32`); only package change since baseline is HistorianGateway Client/Contracts 0.1.0→0.2.0 (`f6eaa267`), pinned as before. |
+| C-4 | 3 legacy xunit 2.9.2 holdouts | **STILL OPEN** — `AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests` still reference `xunit` 2.9.2 (`Directory.Packages.props:108`). |
+| C-5 | No `.editorconfig`; StyleGuide.md says "ScadaBridge" | **STILL OPEN** — no `.editorconfig`; `StyleGuide.md:3` still reads "for all ScadaBridge documentation". |
+| C-6 | csproj boilerplate duplicates Directory.Build.props | **STILL OPEN** — unchanged. |
+| U-1 | (positive) Client.UI is real and maturing | **STILL VALID** — unchanged (126 headless tests); now actually run by CI. |
+| U-2 | Coverage matrix: Host has no unit tier | **STILL OPEN, partially mitigated** — no `Host.Tests` project exists. However the wiring-bug *class* U-2 worried about gained two targeted guards on master: `ResilienceInvokerFactoryRegistrationTests` (Host.IntegrationTests, `8a8b9ec5` — asserts prod DI binds the real invoker factory) and the reflection-exhaustive `DeferredSinkForwardingReflectionTests` (Commons.Tests, `a65c2ced`). A general Host DI-composition unit tier is still the right ask. |
+| U-3 | docs/Client.CLI.md documents 8 of 14 commands | **STILL OPEN** — `ack`/`confirm`/`shelve`/`enable`/`disable` still have zero sections in `docs/Client.CLI.md` (grep re-verified; the `/fixdocs` sweep `3b5ef439` touched only XML doc comments in S7/TwinCAT/Core, not this). |
+| U-4 | Root planning-file sprawl contradicts its own rules | **STILL OPEN** — `looseends.md`, `pending.md`, `current.md`, `stillpending.md`, `HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md` all still tracked (`git ls-files` re-verified), including the two `pending.md` itself forbids staging. |
+| U-5 | 7 orphaned proprietary AVEVA DLLs tracked in `lib/` | **STILL OPEN** — all 7 DLLs still in `git ls-files lib/`; still zero `HintPath` references. |
+| U-6 | sql_login.txt local-only; Wonderware husk dirs on disk | **STILL OPEN (Low, local-only)** — `sql_login.txt` still untracked+gitignored but present on disk; the 5 retired `Driver.Historian.Wonderware*` dirs still exist as ignored husks under `src/Drivers/` + `tests/Drivers/`; `export-clean-copy.bat` still has no explicit sql_login exclusion. |
+| U-7 | Missing CI stages inventory | **PARTIALLY FIXED** — the two biggest items (analyzer enforcement, all unit suites) are now CI-gated via C-1 + S-1. Still absent: `dotnet format`/style, explicit NuGet-audit failing step, `scripts/check-code-reviews-readme.ps1`, docs link/freshness check, and the `ci/ab-server.lock.json` download step referenced by `docs/v2/test-data-sources.md`. |
+| U-8 | CLI security posture is dev-tool-grade | **STILL OPEN** — `ConnectionSettings.AutoAcceptCertificates` still defaults `true` (`Models/ConnectionSettings.cs:41`), `-P` still argv-visible; undocumented in Client.CLI.md. |
+
+**Net:** 2 FIXED (S-1, C-1 — the two Highs), 4 PARTIALLY FIXED (S-2, S-4, P-1, U-7), 17 still
+open/unchanged (all Medium/Low), 3 positives still valid. Every remediation claim in
+`archreview/plans/STATUS.md` that touches this slice checked out against the code, including the
+adversarial checks (suppressions are scoped and commented; the skip-gate is genuinely strict on the
+unit leg; the dispatch-gap pragmas are genuinely gone, not relocated).
+
+---
+
 ## Architecture Overview
 
 ### Client stack
 
-Three projects form a clean layered client stack, all .NET 10:
+Three projects form a clean layered client stack, all .NET 10 — **unchanged since the prior
+review** (zero src/Client commits in the window):
 
 - **`Client.Shared`** — the OPC UA client library. `OpcUaClientService`
   (`src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs`, 975 lines) implements
   `IOpcUaClientService` behind five adapter seams (`IApplicationConfigurationFactory`,
   `IEndpointDiscovery`, `ISessionFactory`, `ISessionAdapter`, `ISubscriptionAdapter` under
-  `Adapters/`) so the OPC Foundation SDK is fully fakeable — the test project supplies
-  `Fakes/Fake*` for every seam. The service owns connect/disconnect, read/write with
-  string→typed value coercion, browse with continuation points, data + alarm subscriptions
-  with **failover replay** (multi-endpoint round-robin on keep-alive failure), Part 9 alarm
-  method calls (Acknowledge/Confirm/Shelve/Enable/Disable), HistoryRead (raw + aggregate),
+  `Adapters/`) so the OPC Foundation SDK is fully fakeable. The service owns connect/disconnect,
+  read/write with string→typed coercion, browse with continuation points, data + alarm
+  subscriptions with failover replay, Part 9 alarm method calls, HistoryRead (raw + aggregate),
   and redundancy-info reads.
-- **`Client.CLI`** — CliFx-based terminal client (`otopcua-cli`). 14 commands
-  (`Commands/`: connect, read, write, browse, subscribe, historyread, alarms, redundancy,
-  acknowledge, confirm, shelve, enable, disable + base). `CommandBase` centralises
-  connection options; `Program.cs` is a 14-line CliFx bootstrap with a type-activator that
-  injects a shared `IOpcUaClientServiceFactory`. Commands are session-per-invocation
-  (create → connect → work → disconnect/dispose in `finally`).
-- **`Client.UI`** — Avalonia 11.2 desktop app (CommunityToolkit.Mvvm). **Real, not
-  vestigial**: ~2,905 lines of C# + 551 lines of AXAML across 10 viewmodels, 9 views, a
-  custom `DateTimeRangePicker` control, JSON settings persistence, and a UI-dispatcher
-  seam (`IUiDispatcher` with a synchronous test double). Covers browse tree, read/write,
-  subscriptions, alarms (ack/confirm/shelve dialogs), and history. 126 headless
-  (Avalonia.Headless) tests.
+- **`Client.CLI`** — CliFx-based terminal client (`otopcua-cli`), 14 commands, session-per-
+  invocation. `CommandBase` centralises connection options and (still) swaps the global
+  Serilog `Log.Logger` per execution (S-6).
+- **`Client.UI`** — Avalonia 11.2 desktop app (CommunityToolkit.Mvvm), ~2.9 k LOC C# + 551
+  lines AXAML, 126 headless tests. Real, not vestigial (U-1).
 
 ### Tooling
 
-`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers` ships exactly one Roslyn analyzer:
-**OTOPCUA0001 `UnwrappedCapabilityCallAnalyzer`** — flags async calls to the seven guarded
-driver-capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`,
-`IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`) that are not wrapped in
-`CapabilityInvoker.ExecuteAsync/ExecuteWriteAsync` (the Polly breaker/retry/bulkhead
-pipeline). Semantically sophisticated (symbol-identity matching, DIM handling,
-wrapper-lambda containment), netstandard2.0, `EnforceExtendedAnalyzerRules`, tracked
-`AnalyzerReleases.*.md`, 31 tests. **But it is wired into zero consuming projects** — see
-finding C-1.
+`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers` ships **OTOPCUA0001 `UnwrappedCapabilityCallAnalyzer`**
+— and it is **now wired into every project in the tree** via `Directory.Build.props:45-51`
+(`OutputItemType="Analyzer"`, excluding only the analyzer and its own test project). The rule it
+enforces is also now *true*: `bacea1a4` wired `CapabilityInvoker` into production dispatch through
+a new `IDriverCapabilityInvoker`/`IDriverCapabilityInvokerFactory` seam in Core.Abstractions
+(mirroring `IDriverFactory`, because Runtime is deliberately Polly-free), routed all 6
+`DriverInstanceActor` dispatch sites through it, taught the analyzer that the interface is a valid
+wrapper home, and deleted the 6 tracked-gap pragmas. 32 analyzer tests. The prior review's
+headline failure mode for this slice — "built and tested but plugged into nothing" — is closed,
+with the analyzer itself as the standing regression guard (error-severity where TWE is on; see
+C-7).
 
 ### Test architecture map
 
-47 test projects mirror `src/` under `tests//` with three tiers:
+47 test projects (verified: `git ls-files 'tests/**/*.csproj' | wc -l` = 47) mirror `src/` under
+`tests//` in three tiers:
 
-1. **`*.Tests`** — pure unit suites (fakes/in-memory), 40 projects. xunit.v3 + Shouldly
-   everywhere except three legacy v2 holdouts still on xunit 2.9.2 (`AdminUI.Tests`,
-   `ControlPlane.Tests`, `Runtime.Tests`).
-2. **`*.IntegrationTests`** — 10 projects needing a live fixture. Pattern: a collection
-   fixture does a **one-shot TCP reachability probe** against an env-var endpoint whose
-   default is the shared Docker host `10.100.0.35` (e.g.
-   `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs:37`
-   — `MODBUS_SIM_ENDPOINT` default `10.100.0.35:5020`), and every test `Assert.Skip`s when
-   unreachable. `dotnet test` therefore passes cleanly offline — by *skipping*.
-3. **`Category=LiveIntegration`** — env-gated live suites against real infrastructure
-   (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs`,
-   gated on `HISTGW_GATEWAY_ENDPOINT` etc.). Skip-clean when env vars absent.
-4. **`Category=E2E`** — reserved; **no project or test carries it yet** (the nightly E2E
-   workflow is a documented no-op).
+1. **`*.Tests`** — pure unit suites, 40 projects; xunit.v3 + Shouldly except three xunit 2.9.2
+   holdouts (`AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests`).
+2. **`*.IntegrationTests`** — 10 projects, probe-once collection fixtures defaulting to the
+   shared Docker host `10.100.0.35`, `Assert.Skip` when unreachable.
+3. **`Category=LiveIntegration`** — env-gated live suites (skip-clean offline).
+4. **`Category=E2E`** — still reserved; **still zero tests carry it** (S-3).
 
-### Build / CI pipeline shape
+### Build / CI pipeline shape (changed since 2026-07-08)
 
-- `Directory.Build.props` — global net10.0 / nullable / implicit usings / latest lang, plus
-  the CVE-2025-6965 `NuGetAuditSuppress` carve-out. `TreatWarningsAsErrors` is deliberately
-  **opt-in per project** (legacy xUnit1051 debt), with a written promotion plan.
-- `Directory.Packages.props` — central package management, all 100+ versions pinned, no
-  floating versions, three inline security-pin rationales (Roslyn 5.0.0 CS9057 pin,
-  OpenTelemetry 1.15.3, Tmds.DBus.Protocol 0.21.3).
-- `NuGet.config` — three sources with **strict packageSourceMapping** (nuget.org wildcard,
-  repo-local `nuget-packages/` for MxGateway, Gitea feed for the ZB.MOM.WW.* shared libs).
-- CI: two workflow files in `.github/workflows/` (remote is Gitea; header claims
-  Gitea-Actions compatibility). `v2-ci.yml`: build → 5-project unit matrix → 2-project
-  integration matrix. `v2-e2e.yml`: nightly docker-dev fleet + `Category=E2E` filter that
-  matches zero tests.
-- `ci/` contains only `ab-server.lock.json` (pinned libplctag release for the AB fixture).
-- `scripts/` — PowerShell operational tooling: `compliance/` (6 phase-gate scripts), `e2e/`
-  (per-driver E2E harnesses + sample config), `install/` (Windows service + Traefik),
-  `migration/`, `smoke/` (SQL seeds), `focas/` (protocol capture), and
-  `check-code-reviews-readme.ps1` (review-index consistency check — not run by CI).
-- `docker-dev/` — the local 8-service dev fleet (SQL, migrator, cluster-seed, central-1/2,
-  site-a/b pairs, Traefik).
+- `Directory.Build.props` — global net10.0/nullable/implicit-usings/latest, the CVE-2025-6965
+  `NuGetAuditSuppress` carve-out (still valid, C-3), and **new**: the tree-wide OTOPCUA0001
+  analyzer injection (lines 34-51). TWE remains opt-in per project.
+- `Directory.Packages.props` — CPM, all versions pinned; only delta since baseline is the
+  HistorianGateway Client/Contracts 0.1.0→0.2.0 bump (`f6eaa267`).
+- CI (`v2-ci.yml`, rewritten by `10b89830`): `build` → **one whole-solution unit leg**
+  (`dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "Category!=E2E&Category!=LiveIntegration"`,
+  trx logger, strict fail-on-skip gate) → the old 2-project integration matrix (same filter,
+  report-only skip gate). **New** `scripts/ci/assert-not-all-skipped.sh` parses trx
+  `` and fails when executed < `MIN_EXECUTED`.
+- `v2-e2e.yml` — untouched; still the documented nightly green no-op (S-3).
+- `docker-dev/` — the compose gained a real fix in the window (`7233e2ba`): the retired
+  Wonderware `ServerHistorian` keys were replaced with the gateway shape
+  (`Endpoint`/`ApiKey`/`UseTls`, disabled by default) after they were found to crash the Host at
+  bring-up — a live-gate catch, exactly the failure mode this review series exists for.
 
 ---
 
@@ -98,314 +125,228 @@ finding C-1.
 Severity scale: **Critical** (broken now, corrupts trust) / **High** (material gap, fix soon)
 / **Medium** (real debt) / **Low** (polish).
 
+Fixed prior findings (S-1, C-1) live only in the status table above. Still-open findings keep
+their IDs; new findings continue each scheme's numbering.
+
 ### 1. STABILITY
 
-#### S-1 (High) — CI gates ~15% of the test matrix; client, tooling, driver, and most core suites are never run by CI
-`.github/workflows/v2-ci.yml:47-52` enumerates **5** unit-test projects (Cluster,
-ControlPlane, Runtime, Security, OpcUaServer) out of **47** in the solution. Everything in
-this review's scope — `Client.CLI.Tests` (104 tests), `Client.Shared.Tests` (158),
-`Client.UI.Tests` (126), `Analyzers.Tests` (31) — plus all driver unit suites, driver CLI
-suites, and most Core suites (Core, Core.Scripting, VirtualTags, ScriptedAlarms,
-AlarmHistorian, Commons, Configuration, Abstractions) run only when a developer remembers
-to run them. The matrix predates most of these projects and was never widened.
-**Recommendation:** replace the hand-maintained matrix with a single
-`dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "Category!=E2E&Category!=LiveIntegration"`
-leg (the skip-gated integration fixtures already tolerate missing endpoints), or generate
-the matrix from the slnx. Any new-project drift then becomes impossible.
-
-#### S-2 (High) — "green CI" for integration tests means "skipped", and nothing distinguishes skip from pass
-The integration job (`v2-ci.yml:61-76`) runs `Host.IntegrationTests` and
-`OpcUaServer.IntegrationTests` on `ubuntu-latest` with **no service containers**, while
-the fixtures default to `10.100.0.35` (unreachable from any hosted runner —
-`ModbusSimulatorFixture.cs:37`, `AbServerFixture.cs`, `Snap7ServerFixture.cs`,
-`OpcPlcFixture.cs`, and `Host.IntegrationTests/DriverTestConnectE2eTests.cs` all
-hard-default to it). Probe-fails → `Assert.Skip` → job green. The design is intentional
-and well-documented for dev boxes, but in CI it silently converts the entire integration
-tier into a no-op with a passing badge. **Recommendation:** in CI, either start the
-fixtures as workflow `services:` (modbus/opc-plc images exist) and set the `*_ENDPOINT`
-env vars to `localhost`, or add a post-test step that fails the job when skipped-count >
-threshold (`--logger trx` + parse), so a silent fixture outage cannot masquerade as green.
-
-#### S-3 (Medium) — the nightly E2E workflow is a permanent green no-op
-`v2-e2e.yml:6-10` says it plainly: the E2E test project "does not yet exist… this workflow
-is a green no-op". No test in the tree carries `Category=E2E` (verified by grep). A
-nightly job that always passes trains people to ignore it; it also boots the full
-docker-dev fleet for nothing. **Recommendation:** either land the minimal E2E round-trip
-project (the `scripts/e2e/test-*.ps1` harnesses show exactly what it should assert) or
+#### S-3 (Medium) — the nightly E2E workflow is still a permanent green no-op
+`v2-e2e.yml:6-10` unchanged: the E2E test project "does not yet exist… this workflow is a green
+no-op", and no test in the tree carries `Category=E2E`. Now that the unit tier is genuinely gated
+(S-1 fixed), this is the largest remaining "always-green" surface. **Recommendation:** land the
+minimal E2E round-trip project (the `scripts/e2e/test-*.ps1` harnesses show what to assert) or
 disable the schedule until it exists.
 
-#### S-4 (Medium) — fixed-sleep timing tests are the dominant wait style
-`grep Task.Delay|Thread.Sleep` across `tests/` (excluding obj) shows heavy fixed-delay
-usage: Driver.Galaxy.Tests (30), Driver.Modbus.Tests (22), Driver.AbCip.Tests (20),
-Host.IntegrationTests (14), and in this slice **Client.CLI.Tests** —
-`SubscribeCommandTests.cs:30,55,78,103`, `AlarmsCommandTests.cs:26,51,76,104,127,149`
-(`await Task.Delay(100)` to let a background command loop start before cancelling),
-`EventHandlerLifecycleTests.cs:54` (150 ms). These pass locally and flake under CI load —
-which is currently masked because CI never runs them (S-1). **Recommendation:** replace
-start-up sleeps with a readiness signal from the fake service (e.g. a
-`TaskCompletionSource` completed on first `SubscribeAsync`), which `FakeOpcUaClientService`
-can expose cheaply.
+#### S-4 (Medium, narrowed) — driver-suite fixed sleeps are now CI-exposed flake risk
+The Client.CLI half of this finding is fixed (readiness signals, `10b89830`). What remains:
+Driver.Galaxy.Tests (30 `Task.Delay`/`Thread.Sleep` occurrences), Driver.Modbus.Tests (22),
+Driver.AbCip.Tests (20), Driver.FOCAS.Tests (18), Host.IntegrationTests (15), Driver.TwinCAT.Tests
+(7). Before S-1's fix these never ran in CI; **now they run on every push**, so their fixed-delay
+assumptions will meet loaded shared runners for the first time. **Recommendation:** watch the
+first weeks of whole-solution CI for flaky suites and apply the same readiness-signal pattern
+(the `FakeOpcUaClientService.SubscribeInvoked` TCS is the template) to whichever driver suites
+actually flake — don't pre-emptively rewrite all ~112 sites.
 
-#### S-5 (Medium) — Client.Shared subscription bookkeeping has lock-discipline gaps
-`OpcUaClientService.cs` documents that `_subscriptionLock` guards the subscription state
-(lines 18-22), but:
-- `SubscribeAsync` checks/creates the shared `_dataSubscription` **outside** the lock
-  (line 262) — two concurrent first-subscribers can both see null and create two SDK
-  subscriptions, leaking one.
-- `RunFailoverAsync` nulls `_dataSubscription` / `_alarmSubscription` **without** the lock
-  (lines 695-696), while `DisconnectAsync`'s comment (lines 138-140) assumes the failover
-  path nulls them under the lock.
-- `UnsubscribeAlarmsAsync` (line 335) does not take `_alarmSubscribeSemaphore`, so an
-  unsubscribe racing a `SubscribeAlarmsAsync` can delete the adapter the subscriber just
-  created and leave `_alarmSubscription` non-null-but-deleted.
-- Keep-alive failover is fire-and-forget (`_ = HandleKeepAliveFailureAsync()`, lines
-  115, 718) — correct re-entrancy guard via `Interlocked.CompareExchange` (line 659), but
-  an exception escaping `TransitionState`'s event invocation is unobserved.
-
-For the CLI's session-per-command usage these races are near-unhittable; for Client.UI
-(long-lived service, UI-thread + keep-alive-thread concurrency) they are real.
-**Recommendation:** move the `_dataSubscription` null-check/create inside a small async
-gate (mirror `_alarmSubscribeSemaphore`), take the lock in `RunFailoverAsync`, and route
-`UnsubscribeAlarmsAsync` through the semaphore.
+#### S-5 (Medium) — Client.Shared subscription bookkeeping still has lock-discipline gaps
+Unchanged from the prior review (`src/Client` had zero commits in the window):
+- `SubscribeAsync` checks/creates the shared `_dataSubscription` outside the lock
+  (`OpcUaClientService.cs:262`) — two concurrent first-subscribers can leak an SDK subscription.
+- `RunFailoverAsync` nulls `_dataSubscription`/`_alarmSubscription` without the lock
+  (lines 695-696), contradicting `DisconnectAsync`'s stated assumption (lines 138-140).
+- `UnsubscribeAlarmsAsync` does not take `_alarmSubscribeSemaphore`.
+- Keep-alive failover remains fire-and-forget with an unobserved exception path out of
+  `TransitionState`.
+Near-unhittable for the session-per-command CLI; real for long-lived Client.UI usage.
+**Recommendation** unchanged: small async gate around the data-subscription create, take the lock
+in `RunFailoverAsync`, route alarm-unsubscribe through the semaphore.
 
 #### S-6 (Medium) — global `Log.Logger` swap per CLI command
-`CommandBase.ConfigureLogging()` (`CommandBase.cs:120-134`) does `Log.CloseAndFlush()` then
-replaces the static `Log.Logger` on every command execution. In-process this is a race for
-any parallel test collections that execute commands concurrently (xunit.v3 parallelises
-collections by default), and it makes the CLI hostile to embedding. The
-`LoggerLifecycleTests` suite exists precisely to police this. **Recommendation:** give
-each command an instance `ILogger` (Serilog `LoggerConfiguration.CreateLogger()` held per
-execution) instead of mutating the global.
+Unchanged: `CommandBase.cs:123` (`Log.CloseAndFlush()`) + `:133` (static `Log.Logger`
+replacement) per command execution. Race for parallel test collections; hostile to embedding.
+**Recommendation:** per-execution instance `ILogger`.
 
-#### S-7 (Low) — no `global.json`, but the build depends on an exact SDK band
-`v2-ci.yml:10-12` claims "The .NET 10 SDK is pinned via global.json at the repo root" —
-**no `global.json` exists**. Meanwhile `Directory.Packages.props:44-49` pins
-`Microsoft.CodeAnalysis.CSharp` to 5.0.0 explicitly because "SDK 10.0.105 ships compiler
-5.0.0.0… until the SDK rolls to 10.0.110+". An SDK roll on a runner or dev box silently
-changes the compiler this pin was matched to. **Recommendation:** add `global.json` with
-`rollForward: latestFeature` and revisit the Roslyn pin note when it lands.
+#### S-7 (Low) — no `global.json`, and the workflow's claim survived its own rewrite
+Still no `global.json`; `v2-ci.yml:10-12` — a file otherwise rewritten in this window — still
+asserts "The .NET 10 SDK is pinned via global.json at the repo root". The
+`Microsoft.CodeAnalysis.CSharp` 5.0.0 pin (`Directory.Packages.props`) still assumes an exact SDK
+band. **Recommendation:** add `global.json` (`rollForward: latestFeature`) or fix the comment.
 
 #### S-8 (Low) — first-caller interval wins for the shared data subscription
-`SubscribeAsync` creates one `_dataSubscription` with the first caller's `intervalMs`
-(`OpcUaClientService.cs:262`); later subscriptions with different intervals join the same
-publish interval silently (the monitored-item sampling interval is honoured, the publish
-cadence is not). Fine for the CLI; surprising for UI users mixing 100 ms and 10 s items.
-Document or create per-interval subscriptions.
+Unchanged (`OpcUaClientService.cs:262`). Document or create per-interval subscriptions.
+
+#### S-9 (Low, NEW) — the strict skip-gate threshold is `MIN_EXECUTED=1`, which only catches total outage
+`scripts/ci/assert-not-all-skipped.sh:19` defaults the floor to 1 executed test, and the unit leg
+uses that default (`v2-ci.yml:66-68`). A whole-solution run currently executes thousands of
+tests; a *partial* discovery outage (e.g. an adapter regression that silently drops the xunit
+2.9.2 or the xunit.v3 population, or a filter typo that matches one project) would still pass the
+gate while losing 95%+ of coverage. The gate as shipped converts "green == nothing ran" into red
+— genuinely valuable — but "green == almost nothing ran" is still possible.
+**Recommendation:** raise the unit-leg floor to a conservative constant (e.g. `MIN_EXECUTED=1500`
+today, revised occasionally), which costs nothing and catches the partial-outage class too.
 
 ### 2. PERFORMANCE
 
-#### P-1 (Medium) — CI does 7 independent restore+build passes per push with no caching
-Every matrix leg in `v2-ci.yml` checks out and implicitly restores/builds from scratch
-(`dotnet test` without `--no-build`), and the dedicated `build` job's outputs are thrown
-away. No `actions/cache` for the NuGet package folder. For a solution this size (70+
-projects) that is the bulk of CI wall-clock. **Recommendation:** cache
-`~/.nuget/packages` keyed on `Directory.Packages.props`, and either share the build via
-artifacts or accept rebuild but add `--no-restore` after an explicit cached restore.
+#### P-1 (Medium, narrowed) — CI still does 4 independent uncached restore+build passes per push
+Improved from 7 legs to 4 (`build`, `unit-tests`, 2× `integration`), but each job still checks
+out, restores, and rebuilds from scratch; the dedicated `build` job's output is still discarded;
+there is still no `actions/cache` for `~/.nuget/packages`. **Recommendation:** cache the NuGet
+folder keyed on `Directory.Packages.props`; consider dropping the redundant integration matrix
+(P-5), which removes half the remaining waste for free.
 
 #### P-2 (Low) — recursive browse is N+1 over `HasChildrenAsync`
-`OpcUaClientService.BrowseAsync` issues an extra `HasChildrenAsync` round-trip per Object
-child (`OpcUaClientService.cs:230-231`), and `SubscribeCommand.CollectVariablesAsync`
-walks the tree serially (`SubscribeCommand.cs:256-281`). On the fleet address space
-(thousands of equipment folders) `subscribe -r` start-up is dominated by this. Acceptable
-for a diagnostic tool; batch the browse (`BrowseNext` on multiple nodes / read
-`References` in bulk) if it becomes a soak-test bottleneck.
+Unchanged (`OpcUaClientService.cs:230-231`; `SubscribeCommand` serial tree walk). Acceptable for
+a diagnostic tool.
 
 #### P-3 (Low, positive) — integration-fixture cost is well-engineered
-The probe-once collection-fixture pattern (`ModbusSimulatorFixture` remarks: "checking
-every test would waste several seconds against a firewalled endpoint") is consistently
-applied across all 10 IntegrationTests projects, and fixtures do not hold sockets open.
-No per-test container spin-up anywhere. This is the right shape; the problem is what CI
-does with it (S-2), not the fixtures themselves.
+Unchanged and now more load-bearing: the probe-once collection-fixture pattern is what makes the
+whole-solution CI leg viable at all (10 IntegrationTests projects probe `10.100.0.35` once each
+and skip, rather than timing out per test).
 
 #### P-4 (Low) — CLI subscription output path is allocation-sane
-`SubscribeCommand` serialises SDK callbacks through an unbounded single-reader `Channel`
-(`SubscribeCommand.cs:118-119`) rather than locking the console writer — correct and
-cheap. The unbounded channel could balloon if the console blocks while thousands of
-monitored items update; a bounded channel with `DropOldest` would cap it. Cosmetic at
-current scale.
+Unchanged; the unbounded channel remains a cosmetic cap-it-later.
+
+#### P-5 (Low, NEW) — the CI integration matrix is now fully redundant with the unit leg
+The whole-solution unit leg (`v2-ci.yml:58-62`) already builds and runs
+`Host.IntegrationTests` and `OpcUaServer.IntegrationTests` under the identical filter — so the
+`integration` job (`v2-ci.yml:77-106`) re-restores, re-builds, and re-runs the same two projects
+in the same fixtureless environment, producing the same skips, twice per push. Its only unique
+output is a per-project skip tally that the unit leg's trx already contains. (Note
+Host.IntegrationTests is not pure skip — its in-process 2-node Akka harness tests, e.g.
+`HardKillFailoverTests` and `ResilienceInvokerFactoryRegistrationTests`, actually execute — but
+they execute in **both** jobs.) **Recommendation:** either delete the matrix, or make it earn its
+keep by becoming the S-2 option-#2 leg: add the `mcr.microsoft.com/iotedge/opc-plc` `services:`
+block, set `OPCUA_SIM_ENDPOINT=localhost:50000`, and flip its gate to strict — then it covers
+something the unit leg cannot.
 
 ### 3. CONVENTIONS
 
-#### C-1 (High) — the custom analyzer is wired into **zero** projects; OTOPCUA0001 enforces nothing
-A repo-wide grep of every `.csproj`, `.props`, and `.targets` finds only two references to
-`ZB.MOM.WW.OtOpcUa.Analyzers`: its own csproj and its test project's `ProjectReference`.
-No consuming project references it with `OutputItemType="Analyzer"`, and
-`Directory.Build.props` does not inject it. The carefully built rule — "every
-IReadable/IWritable/… call must route through CapabilityInvoker" — is enforced **only by
-its 31 unit tests asserting the analyzer itself works**, not against the actual Core/
-Server/Driver code it was written to police. This is the same failure mode the project
-has already been bitten by twice at runtime (the dormant `GatewayTagProvisioner`, the
-non-forwarding `DeferredAddressSpaceSink`): a capability built and tested but never
-plugged in. **Recommendation:** add to `Directory.Build.props` (conditioned on
-`'$(MSBuildProjectName)' != 'ZB.MOM.WW.OtOpcUa.Analyzers'`):
-```xml
-
-  
-
-```
-then triage the first wave of OTOPCUA0001 warnings (add the documented test-project
-`NoWarn` where intended).
+#### C-2 (Medium) — `TreatWarningsAsErrors` still never reached the Client/Tooling slice (or most tests)
+Unchanged: `Client.CLI.csproj`, `Client.Shared.csproj`, `Client.UI.csproj`, and
+`Analyzers.csproj` still lack TWE; still 9/47 test projects opted in. The prior argument stands
+(the client stack is v2-era code with no xUnit1051 legacy), and the stakes rose slightly: TWE is
+now also the mechanism that gives OTOPCUA0001 teeth (C-7). **Recommendation:** add TWE to the
+four src projects now; burn down test debt per module.
 
-#### C-2 (Medium) — `TreatWarningsAsErrors` opt-in never reached the Client/Tooling slice (or most tests)
-`Directory.Build.props:2-11` explains TWE is per-project pending legacy cleanup, and every
-Core, Server, Driver, and Driver-CLI **src** project has opted in — but
-`Client.CLI.csproj`, `Client.Shared.csproj`, `Client.UI.csproj`, and
-`Analyzers.csproj` have not, and only 9 of 47 test projects have. The comment's stated
-blocker ("pre-v2 test projects… xUnit1051") does not apply to the client stack, which is
-v2-era code. **Recommendation:** add TWE to the four remaining src projects now (they
-build warning-clean or nearly so), and burn down the test-project debt per module.
+#### C-3 (Low, positive) — central package management discipline remains exemplary
+Re-verified at `f6eaa267`: CPM pinned throughout, `packageSourceMapping` intact, the
+GHSA-2m69-gcr7-jv3q `NuGetAuditSuppress` still present/scoped/accurate with its removal condition
+(`Directory.Build.props:19-32`), the OpenTelemetry + Tmds.DBus.Protocol surgical pins unchanged.
+The only version delta in the window (HistorianGateway 0.1.0→0.2.0) followed the pattern. Keep
+the removal reminders alive.
 
-#### C-3 (Low) — central package management discipline is exemplary; the audit carve-out is still valid
-Verified current state: CPM enabled, every version pinned, `packageSourceMapping` prevents
-dependency-confusion for the private `ZB.MOM.WW.*` namespaces (`NuGet.config:9-29`), and
-the `NuGetAuditSuppress` for GHSA-2m69-gcr7-jv3q (`Directory.Build.props:19-32`) is still
-present, still scoped to a single advisory, and still accurate as written (transitive
-SQLitePCLRaw native bundle, no patched release; documented removal condition). The two
-transitive CVE pins (OpenTelemetry 1.15.3 at `Directory.Packages.props:80-87`,
-Tmds.DBus.Protocol 0.21.3 at lines 104-107 with the matching direct reference in
-`Client.UI.csproj`) follow the memory-documented "surgical direct reference" strategy.
-No action; keep the removal reminders alive.
+#### C-4 (Low) — three xunit 2.9.2 holdouts remain
+Unchanged: `AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests` still on `xunit` 2.9.2
+(`Directory.Packages.props:108`). Now mildly riskier than before: the whole-solution CI leg runs
+a mixed v3/2.x population through one `dotnet test` invocation, so the holdouts are a moving part
+in the new pipeline. Finish the migration and drop the pin.
 
-#### C-4 (Low) — test naming/layout is consistent; one categorisation deviation
-`*.Tests` vs `*.IntegrationTests` naming is uniform and mirrors `src/` exactly. The single
-deviation: live-gated tests live *inside* a unit-suite project
-(`Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs`,
-`Category=LiveIntegration`) rather than a `*.IntegrationTests` sibling — harmless because
-they skip-gate on env vars, but a `dotnet test tests/Drivers/...Gateway.Tests` run now has
-a hidden live-test dependency surface. Framework split: 44 projects on xunit.v3, 3 legacy
-on xunit 2.x (`AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests`) — finish the
-migration and drop the `xunit` 2.9.2 pin from `Directory.Packages.props:108`.
+#### C-5 (Low) — still no mechanical code-style enforcement; StyleGuide.md still says "ScadaBridge"
+Unchanged: no `.editorconfig`, no `dotnet format` CI step, `StyleGuide.md:3` still carries the
+sister-repo product name.
 
-#### C-5 (Low) — no mechanical code-style enforcement exists; StyleGuide.md is a *docs* style guide
-There is **no `.editorconfig`** in the repo, no `dotnet format` CI step, and
-`StyleGuide.md` is exclusively a documentation-writing guide — whose opening line still
-says "for all **ScadaBridge** documentation" (`StyleGuide.md:3`), a copy-paste from the
-sister repo. Code style is therefore enforced only by review culture. **Recommendation:**
-add a root `.editorconfig` (the implicit conventions are already consistent — file-scoped
-namespaces, 4-space, `_camelCase` fields) and fix the StyleGuide title/product name.
+#### C-6 (Low) — csproj boilerplate still duplicates Directory.Build.props
+Unchanged; strip `TargetFramework`/`Nullable`/`ImplicitUsings` restatements on next sweep.
 
-#### C-6 (Low) — csproj boilerplate duplicates Directory.Build.props
-Nearly every csproj restates `TargetFramework`/`Nullable`/`ImplicitUsings` that
-`Directory.Build.props:12-17` already supplies (e.g. `Client.CLI.csproj:5-8`). Harmless
-but it means an SDK bump requires touching 70+ files instead of one. Strip on next sweep.
+#### C-7 (Low, NEW) — OTOPCUA0001 enforcement strength is TWE-dependent, so the analyzer is warning-only exactly where TWE is missing
+The analyzer ships OTOPCUA0001 at Warning severity; it fails builds only in projects with
+`TreatWarningsAsErrors` (e.g. Runtime, where the `bacea1a4` negative control proved
+unwrap→build-error). That covers the real dispatch surface today — Core/Server/Runtime/Driver src
+projects are TWE'd (63 src csprojs opted in). But in the C-2 gap set (Client/Tooling src, 38 test
+projects) a new unwrapped capability call surfaces as a non-failing warning that whole-solution
+CI output will bury. Low because no plausible dispatch code lives in those projects *today*;
+listed so the interaction is on record: **closing C-2 is also what hardens C-1's guard**, and if
+TWE promotion stalls, an alternative is escalating OTOPCUA0001 to error-by-default in the
+analyzer itself (its intentional-call story is now mature enough — scoped pragmas + documented
+NoWarn projects).
 
 ### 4. UNDERDEVELOPED AREAS
 
 #### U-1 — Client.UI maturity: real product, adequately tested, current docs
-Verdict: **not vestigial**. ~2.9 k LOC C#, 551 lines AXAML, MVVM with dispatcher and
-settings seams, alarm ack/confirm/shelve dialogs, history view with custom range picker,
-126 headless tests, and `docs/Client.UI.md` matches the code (verified stack table and
-window-layout claims). It is the least exercised layer in anger (no CI — S-1 — and no
-E2E), and headless tests can't catch AXAML binding regressions (the AdminUI "no bUnit —
-live-verify" lesson applies equally here). Rating it "maturing", not "underdeveloped".
+Verdict unchanged: **not vestigial** (~2.9 k LOC, 126 headless tests, accurate
+`docs/Client.UI.md`). Upgrade since the prior review: those 126 tests now actually run in CI on
+every push. The AXAML-binding blind spot (headless tests can't catch binding regressions — the
+AdminUI "live-verify" lesson) still applies.
 
-#### U-2 (Medium) — source → test coverage matrix: three structural gaps
-Matrix over the 41 solution src projects (test projects verified against the slnx):
+#### U-2 (Medium) — source → test coverage: Host still has no unit tier
+The matrix is otherwise unchanged and nearly complete (Contracts projects remain DTO-only with
+consuming-suite coverage; Core.Scripting.Abstractions remains incidental). **Host is still the
+one real hole** — no `tests/Server/*Host.Tests` exists. Two mitigations landed in the window and
+narrow the risk without closing it: `ResilienceInvokerFactoryRegistrationTests`
+(`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs`,
+asserts production DI binds the *real* invoker factory — precisely the PR-#423-class dormancy
+check this finding asked for, albeit living in the integration project) and the
+reflection-exhaustive deferred-sink forwarding guard (Commons.Tests). **Recommendation** stands:
+a lightweight `Host.Tests` asserting DI composition claims (`ServerHistorian:Enabled` ⇒
+`GatewayTagProvisioner`, etc.) at unit speed.
 
-| Source project | Unit tests | Integration | Gap |
-|---|---|---|---|
-| Client.Shared | ✅ Client.Shared.Tests (158) | — | none |
-| Client.CLI | ✅ Client.CLI.Tests (104) | — | none |
-| Client.UI | ✅ Client.UI.Tests (126, headless) | — | none |
-| Tooling/Analyzers | ✅ Analyzers.Tests (31) | — | **not wired into builds (C-1)** |
-| Core, Commons, Cluster, Configuration, Core.Scripting, VirtualTags, ScriptedAlarms, AlarmHistorian, Core.Abstractions | ✅ each | — | none |
-| **Core.Scripting.Abstractions** | ❌ | — | no dedicated tests (thin interfaces; covered incidentally by Core.Scripting.Tests) |
-| AdminUI, ControlPlane, Runtime, Security, OpcUaServer | ✅ each | OpcUaServer.IntegrationTests | none |
-| **Host** | ❌ **no Host.Tests** | Host.IntegrationTests only | Host's DI/wiring logic (the layer where both "dormant wiring" bugs lived) has no unit tier |
-| All 8 protocol/gateway drivers + Modbus.Addressing + 2 Browsers + Historian.Gateway | ✅ each | 7 × IntegrationTests + LiveIntegration | none |
-| **8 × `*.Contracts` projects** (Galaxy, Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS, OpcUaClient) | ❌ | — | DTO-only; consuming-suite coverage; OpcUaClient.Contracts NamespaceMap gap already known-deferred |
-| 7 × Driver CLIs + Cli.Common | ✅ each | — | none |
+#### U-3 (Medium) — docs drift: `docs/Client.CLI.md` still documents 8 of 14 commands
+Re-verified: `ack`, `confirm`, `shelve`, `enable`, `disable` still have zero sections (the only
+grep hits are incidental words in the alarms section), while CLAUDE.md still points readers at
+this doc for those commands. The window's doc work (`3b5ef439`) was XML-comment-only and did not
+touch this. The "104 unit tests" claim remains accurate (re-counted: 104). **Recommendation:**
+add the five missing command sections — they are the operator-facing alarm workflow.
 
-The one gap worth acting on is **Host**: it is where registration/forwarding mistakes
-land, and its only automated coverage requires the 2-node harness. A `Host.Tests` project
-asserting DI composition (e.g. "when `ServerHistorian:Enabled`, `IHistorianProvisioning`
-resolves to `GatewayTagProvisioner` and the applier receives it") would have caught the
-PR #423 dormancy at unit speed.
+#### U-4 (Medium) — repo-root planning-file sprawl still contradicts its own rules
+Unchanged: `looseends.md`, `pending.md`, `current.md`, `stillpending.md`,
+`HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md` all still tracked at the root, and `pending.md` still
+declares the hard rule against staging `pending.md`/`current.md` — which remain tracked. No
+remediation branch addressed this (as expected — it had none). **Recommendation** unchanged:
+move under `docs/plans/` with dated names, or delete + gitignore.
 
-#### U-3 (Medium) — docs drift: `docs/Client.CLI.md` documents 8 of 14 commands
-Spot-checks performed:
-1. `docs/Client.CLI.md` command sections: connect, read, write, browse, subscribe,
-   historyread, alarms, redundancy — **`ack`, `confirm`, `shelve`, `enable`, `disable`
-   have zero mentions** (grep), yet the commands ship
-   (`Commands/AcknowledgeCommand.cs`, `ConfirmCommand.cs`, `ShelveCommand.cs`,
-   `EnableCommand.cs`, `DisableCommand.cs`) and CLAUDE.md explicitly says "Client.CLI
-   supports `ack`, `confirm`, `shelve` commands. See `docs/Client.CLI.md` for full
-   documentation." Five commands undocumented.
-2. The doc's "104 unit tests" claim (`docs/Client.CLI.md`, Testing section) — **accurate**:
-   exactly 104 `[Fact]`/`[Theory]` in Client.CLI.Tests today.
-3. `docs/Client.UI.md` stack/layout claims — **accurate** vs csproj and Views/.
-4. `v2-ci.yml:10-12` global.json claim — **false** (S-7).
-Recommendation: add the five missing command sections; they are the operator-facing alarm
-workflow.
+#### U-5 (Medium) — orphaned proprietary AVEVA DLLs still tracked in `lib/`
+Unchanged: all 7 vendor binaries (`aahClient*.dll`, `ArchestrA.MXAccess.dll`,
+`ArchestrA.CloudHistorian.Contract.dll`, `Historian.CBE.dll`, `Historian.DPAPI.dll`) still in
+`git ls-files lib/`, still zero `HintPath` references repo-wide. Redistribution/licence risk in
+every clone. **Recommendation:** `git rm -r lib/`.
 
-#### U-4 (Medium) — repo-root planning-file sprawl contradicts its own rules
-Five point-in-time planning/state files are committed at the root: `looseends.md` (state
-as of 2026-05-18), `pending.md` (2026-06-16), `stillpending.md`, `current.md`,
-`HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md`. `pending.md` itself declares "HARD RULE: never
-`git add .`; never stage `pending.md` / `current.md` / …" — **yet `pending.md` and
-`current.md` are tracked** (they appear in `git ls-files`; last commits `cd20c3c0`,
-`384dbd7d`). The memory index says the stillpending backlog is ~95% shipped, so most of
-this content is stale snapshots that now contradict `docs/` and CLAUDE.md.
-**Recommendation:** either move them under `docs/plans/` with dated names (the existing
-convention) or delete + gitignore them; make the file's own rule true.
+#### U-6 (Low) — local-only hygiene: sql_login.txt and Wonderware husk dirs
+Unchanged: `sql_login.txt` remains untracked + gitignored (`.gitignore:47`) but is still a
+plaintext credential on disk at the root; `export-clean-copy.bat` still lacks an explicit
+exclusion for it. The five retired `Driver.Historian.Wonderware*` directories still exist on disk
+as ignored `bin/obj` husks — safe to delete locally.
 
-#### U-5 (Medium) — orphaned proprietary AVEVA DLLs tracked in `lib/`
-`lib/` contains 7 **committed** vendor binaries (`aahClient.dll`, `aahClientCommon.dll`,
-`aahClientManaged.dll`, `ArchestrA.MXAccess.dll`, `ArchestrA.CloudHistorian.Contract.dll`,
-`Historian.CBE.dll`, `Historian.DPAPI.dll`). No csproj anywhere references them (zero
-`HintPath` hits repo-wide) — they are leftovers from the retired Wonderware sidecar /
-in-process MXAccess era. Committed proprietary SDK DLLs are a redistribution/licence risk
-in any clone of this repo and dead weight in history. **Recommendation:** `git rm -r lib/`
-(the bitness/COM story now lives entirely in the mxaccessgw repo, per CLAUDE.md).
-
-#### U-6 (Low) — secrets hygiene: `sql_login.txt` is NOT committed (gitignored), but is still a plaintext credential at the root
-Verified: `.gitignore:47` lists it, `git check-ignore` confirms, and
-`git log -- sql_login.txt` is empty — the file has never been committed. The remaining
-risk is purely local (plaintext `wwadmin` password for `wonder-sql-vd03` sitting in a
-Desktop directory that agents and sync tools read). **Recommendation:** move to
-`dotnet user-secrets` / an env file outside the repo; at minimum keep it out of any
-export bundle (`export-clean-copy.bat` at the root should be checked to exclude it).
-Also note the retired `Driver.Historian.Wonderware*` project dirs still exist on disk as
-ignored `bin/obj` husks — local-only debris, safe to delete.
-
-#### U-7 (Low) — missing CI stages (inventory)
-Not gated anywhere today: analyzer tests + analyzer enforcement (C-1), all client/driver/
-core unit suites (S-1), `dotnet format`/style (C-5), NuGet vulnerability audit as an
-explicit failing step (currently only implicit in restore warnings, which are not TWE'd
-in most projects), the `scripts/check-code-reviews-readme.ps1` consistency check, and any
-docs link/freshness check. The `ci/ab-server.lock.json` fixture pin references a
-"GitHub Actions step" in `docs/v2/test-data-sources.md` that does not exist in either
-workflow — the AB fixture download step was planned but never landed.
+#### U-7 (Low, narrowed) — missing CI stages inventory
+The two biggest gaps closed (analyzer enforcement via C-1's fix; all unit suites via S-1's).
+Still not gated anywhere: `dotnet format`/style (blocked on C-5's missing `.editorconfig`), an
+explicit failing NuGet-audit step, `scripts/check-code-reviews-readme.ps1`, docs link/freshness
+checks, and the `ci/ab-server.lock.json` fixture-download step that
+`docs/v2/test-data-sources.md` still describes as an existing "GitHub Actions step".
 
 #### U-8 (Low) — CLI security posture is dev-tool-grade by construction
-`CommandBase.CreateConnectionSettings()` hardcodes `AutoAcceptCertificates = true`
-(`CommandBase.cs:91`), so `--security signandencrypt` encrypts but never authenticates the
-server (any cert accepted → MITM-able), and `-P` takes the password as a process-visible
-argv. Acceptable for a diagnostic tool, but the doc should say so, and a
-`--strict-certs` opt-in would be cheap. Also `Client.Shared`'s alarm fallback path bakes
-Galaxy-specific attribute names (`.InAlarm`, `.Acked`, `.TimeAlarmOn`, `.DescAttrName`)
-into the generic client library (`OpcUaClientService.cs:851-871`) — a documented but
-layering-violating convenience.
+Unchanged: `ConnectionSettings.AutoAcceptCertificates` defaults `true`
+(`Models/ConnectionSettings.cs:41`, flowed through
+`DefaultApplicationConfigurationFactory.cs:53,63`), `-P` is argv-visible, and the Galaxy-specific
+alarm attribute names remain baked into the generic `Client.Shared` fallback path. Document the
+posture; add a `--strict-certs` opt-in.
+
+#### U-9 (Low, NEW) — `archreview/plans/STATUS.md` merged to master still claims nothing is merged
+The remediation handoff doc now lives on pushed master, but its topology header still reads
+"Branch topology (all LOCAL — nothing pushed, nothing on master)" (`STATUS.md:6`) and the resume
+facts still assert "**Nothing is pushed.** 8 local branches" (`STATUS.md:109`) — both now false
+(all listed branches are merged; master is pushed). Same class as the S-7 comment drift: a
+bookkeeping doc that contradicts observable reality erodes trust in the parts that are still
+accurate (e.g. the genuinely-pending #10 live behavioral gate). **Recommendation:** one-paragraph
+update to STATUS.md marking the topology section historical and recording the merge date.
 
 ---
 
 ## Maturity Ratings (1 = ad hoc, 5 = exemplary)
 
-| Dimension | Rating | Justification |
-|---|---|---|
-| Stability | **2.5** | The client code and fixture patterns are careful, but CI runs ~15% of the suite, the integration tier silently skips to green on hosted runners, and the nightly E2E is a documented no-op — the safety net exists mostly on developer machines. |
-| Performance | **3.5** | Build/test structure is lean (CPM, probe-once fixtures, channel-serialised CLI output); loses points for 7× uncached restore/builds in CI and the N+1 recursive browse. |
-| Conventions | **3** | Package management and test layout are exemplary and self-documenting, but the flagship convention-enforcement tool (the analyzer) is wired into nothing, TWE never reached the client slice, and there is no .editorconfig — conventions hold by culture, not mechanism. |
-| Underdeveloped areas | **3** | Client.UI is genuinely mature and the coverage matrix is nearly complete (Host is the one real hole); dragged down by 5-command docs drift, committed stale planning files that violate their own rules, and orphaned proprietary DLLs in `lib/`. |
+| Dimension | 2026-07-08 | 2026-07-12 | Justification |
+|---|---|---|---|
+| Stability | 2.5 | **3.5** | The two structural holes are closed: CI now runs the whole solution (S-1) and all-skipped can't masquerade as green on the unit leg (S-2 strict gate); the newly-CI'd CLI suite was deflaked in the same change. Held below 4 by the still-no-op nightly E2E (S-3), the now-CI-exposed driver-suite fixed sleeps (S-4), the untouched Client.Shared lock gaps (S-5), and a first post-push CI run not yet observed. |
+| Performance | 3.5 | **3.5** | Net wash: matrix collapse cut CI legs 7→4, but restore/build remains uncached, the build job's output is still discarded, and the integration matrix now duplicates work the unit leg already does (P-5). Client-side perf posture unchanged. |
+| Conventions | 3 | **4** | The flagship convention tool went from wired-into-nothing to enforced tree-wide with a proven negative control — and the gap it surfaced (RESILIENCE-DISPATCH-GAP) was actually fixed rather than pragma'd away, with the analyzer left as the standing guard. Suppressions are scoped and commented. Held below 4.5 by the TWE gap that leaves the analyzer warning-only outside the TWE'd projects (C-2/C-7), three xunit 2.x holdouts, and still no `.editorconfig`. |
+| Underdeveloped areas | 3 | **3** | Nothing in this bucket moved: `lib/` DLLs, root planning-file sprawl, the 5-command docs drift, and the missing Host unit tier are all exactly as found (none had a remediation branch), and the merged STATUS.md added a new small drift (U-9). Partial credit for the two Host-wiring guard tests that landed en route. |
 
 ---
 
 ## Top recommendations (ordered)
 
-1. Wire `ZB.MOM.WW.OtOpcUa.Analyzers` into every project via `Directory.Build.props` (C-1).
-2. Widen `v2-ci.yml` to the whole solution and make skipped integration tests visible/failing in CI (S-1, S-2).
-3. Delete tracked `lib/` vendor DLLs (U-5) and resolve the root planning-file contradiction (U-4).
-4. Add the five missing command sections to `docs/Client.CLI.md` (U-3).
-5. Add `global.json` (S-7), `Host.Tests` (U-2), and TWE to the four client/tooling csprojs (C-2).
-6. Fix the three `OpcUaClientService` lock-discipline gaps before Client.UI grows long-lived multi-thread usage (S-5).
+1. Delete the tracked `lib/` vendor DLLs (U-5) and resolve the root planning-file contradiction (U-4) — the two remaining findings with licence/trust implications, both zero-risk removals.
+2. Add the five missing alarm-command sections to `docs/Client.CLI.md` (U-3).
+3. Make the CI integration matrix earn its keep or delete it: add the opc-plc `services:` fixture + flip its skip-gate strict (P-5, completing S-2), and raise the unit-leg `MIN_EXECUTED` floor above 1 (S-9).
+4. Add TWE to the four Client/Tooling src projects (C-2) — this is also what gives OTOPCUA0001 build-breaking teeth there (C-7).
+5. Add `global.json` and fix the workflow comment (S-7); update the stale STATUS.md topology claims (U-9).
+6. Fix the three `OpcUaClientService` lock-discipline gaps before Client.UI grows long-lived multi-thread usage (S-5); land `Host.Tests` (U-2).
+7. Watch the first weeks of whole-solution CI for driver-suite sleep flakes (S-4) and either land a real E2E project or disable the nightly no-op (S-3).
diff --git a/archreview/plans/00-INDEX.md b/archreview/plans/00-INDEX.md
index 071b6148..d3605145 100644
--- a/archreview/plans/00-INDEX.md
+++ b/archreview/plans/00-INDEX.md
@@ -1,8 +1,40 @@
 # Arch-Review Remediation Plans — Index
 
-> **📊 Remediation status → [`STATUS.md`](STATUS.md).** As of 2026-07-08: **2 of 4 Criticals done**
-> (03/S1 split-brain resolver; 02/U2+U3 VT timeout+ALC) + both CLAUDE.md doc-drifts fixed. Each Critical
-> is on its own `fix/archreview-crit*` branch off master; plans live only on `docs/archreview-plans-and-drift-fixes`.
+> **📊 Remediation status → [`STATUS.MD`](STATUS.md).** Round 1 (2026-07-08 review at `9cad9ed0`) is
+> **fully remediated and merged to master** — all 4 Criticals + guards + follow-ups #9–#13. The
+> 2026-07-12 **re-review at `f6eaa267`** produced an updated 12-item action list
+> ([`../00-OVERALL.md`](../00-OVERALL.md)) — see **Round 2 plans** below.
+
+## Round 2 plans (2026-07-12, verified at `f6eaa267`)
+
+One plan per action item in the updated `00-OVERALL.md` prioritized list. Each verifies its findings
+against the tree, gives root cause → design (with alternatives) → file-level steps → tests → effort/risk,
+and ends with a bite-sized TDD task breakdown (classification / estimate / parallelizability) plus a
+co-located `.tasks.json` for resumable execution.
+
+| # | Plan | Findings | Tasks | Effort |
+|---|---|---|---|---|
+| 1 | [R2-01 S7 fault-path hardening](R2-01-s7-fault-hardening-plan.md) | 05/STAB-14 (High regression), STAB-15, STAB-8 seam note | 13 | S-M (~2–3 h) |
+| 2 | [R2-02 ResilienceConfig hardening](R2-02-resilience-config-hardening-plan.md) | 01/S-6 (High, +2nd brick vector), U-6 (delete), S-8, 04/C-7, S-7 race | 19 | S-M (~1 day) |
+| 3 | [R2-03 VT failure quality](R2-03-vt-failure-quality-plan.md) | 02/S13, S12, P7 | 12 | S (~2–3 h) |
+| 4 | [R2-04 Failure-visibility trio](R2-04-failure-visibility-plan.md) | 01/S-1, 06/S-1, 03/S4 (+ scripted-alarm emit gate) | 15 | M (~1.5–2 d + rig) |
+| 5 | [R2-05 AdminUI authz](R2-05-adminui-authz-plan.md) | 04/C-1 (38-page census; ConfigEditor + AuthenticatedRead policies + reflection guard) | 16 | S-M (~2.5–4 h) |
+| 6 | [R2-06 ServerHistorian fail-fast + 0.2.0 DataType](R2-06-serverhistorian-failfast-plan.md) | 06/S-11, U-7 (EnsureTags = upsert ⇒ retype probe), C-7 | 12 | S (~1 h + VPN gate) |
+| 7 | [R2-07 Surgical pure-adds](R2-07-surgical-pure-adds-plan.md) | 03/P1 — 3 phases; Phase 1 needs zero new sink members | 15 | L (~4–6 d) |
+| 8 | [R2-08 Async LDAP + HistoryRead](R2-08-ldap-historyread-async-plan.md) | 03/S2 (SDK raises callback under lock — worse than reported), S3 | 14 | M (~1.5–2 d) |
+| 9 | [R2-09 Driver fleet batch](R2-09-driver-fleet-batch-plan.md) | 05/STAB-3/4/5/8/12/16/17, ResolveHost, onError — 6 PR-sized sub-batches | 29 | L (~5.5–7 d) |
+| 10 | [R2-10 Resilience observability](R2-10-resilience-observability-plan.md) | 03/U10 = 04/U-5 — DPS mirror of the driver-health flow (+2 tracker bugs) | 11 | M (~1 day) |
+| 11 | [R2-11 TagConfig consolidation](R2-11-tagconfig-consolidation-plan.md) | 01/C-1, P-1 + 05 ReadEnum/Writable — 3-phase strictness rollout | 24 | M (~2–2.5 d) |
+| 12 | [R2-12 Repo hygiene](R2-12-repo-hygiene-plan.md) | 07/U-3/4/5/6, S-3, P-5, C-4/5/6 — destructive steps gated on sign-off | 13 | S (~½ day) |
+
+**Suggested execution order:** 1 → 2 → 3 (small, close everything new-and-sharp from the re-review) →
+5 → 4 (the two-round-old Highs) → 6 → 12 (quick wins) → 10 → 11 → 8 → 9 → 7 (large). R2-01/02/03/05/06/12
+have no cross-plan dependencies and can run in parallel branches; R2-09's B3 (PollGroupEngine v2) has
+explicit landing-order notes against R2-01; R2-11 Part 1 must precede any parse-site edits elsewhere.
+
+---
+
+## Round 1 (2026-07-08 at `9cad9ed0`) — COMPLETE, kept for reference
 
 Design + implementation plans for every finding in the 2026-07-08 architecture review
 (`archreview/*.md`, review commit `9cad9ed0`). Each plan verifies every finding against
diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md b/archreview/plans/R2-01-s7-fault-hardening-plan.md
new file mode 100644
index 00000000..c77dd0fa
--- /dev/null
+++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md
@@ -0,0 +1,732 @@
+# Design + Implementation Plan — R2-01 S7 Fault-Path Hardening
+
+**Source report:** `archreview/05-protocol-drivers.md` (2026-07-12 re-review)
+**Plan verified against tree at:** `f6eaa267` (master, clean)
+**Scope:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/` (`S7Driver.cs`, `IS7Plc.cs`) + `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` + `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/`
+**Action-list item:** #1 in `archreview/00-OVERALL.md` — "S7: filter connect-timeout OCE in the ensure-wrapper + poll-loop catches; broaden fatal classification (framing/cancel); mark handle dead in probe catch. Add fake-factory connect-timeout test" (05/STAB-14 + 05/STAB-15; STAB-8's S7 seam noted for R2-09).
+
+> Both findings are residuals of the Critical-3 fix (`25c0c6f6`, round-1 plan §A1/STAB-1,
+> `archreview/plans/05-protocol-drivers-plan.md`). STAB-14 is a **regression inside that fix** —
+> it re-opens the exact scenario class (transient outage permanently kills S7 subscriptions)
+> that Critical 3 was merged to close, just via connect-*timeout* instead of no-reconnect.
+> Modbus's twin defect (STAB-3, same desync class as STAB-15) is **out of scope** here — the
+> report's remediation order pairs it with STAB-15 conceptually, but it lives in
+> `ModbusTcpTransport.cs` and is tracked separately.
+
+---
+
+## Verification summary
+
+Every cited line was opened at `f6eaa267`. **Both findings CONFIRMED. Three citations drifted
+by ≤6 lines** (the report was written against the same commit but a few ranges anchor on doc
+comments rather than code); corrected refs below are used throughout this plan.
+
+| Report citation | Status at `f6eaa267` | Corrected ref |
+|---|---|---|
+| STAB-14 — `S7Driver.cs:488` ensure-wrapper `catch (OperationCanceledException) { throw; }` in `ReadAsync` | **CONFIRMED exact** — unconditional rethrow, no `when` filter | `:488` |
+| STAB-14 — `S7Driver.cs:1000` same wrapper in `WriteAsync` | **CONFIRMED exact** | `:1000` |
+| STAB-14 — `S7Driver.cs:1383, 1404` `PollLoopAsync` bare `catch (OperationCanceledException) { return; }` | **CONFIRMED exact** (initial-read catch `:1383`, tick catch `:1404`; the `Task.Delay` catch at `:1396-1397` is a third bare OCE-return) | `:1383, :1396-1397, :1404` |
+| STAB-14 — `S7Driver.cs:1206-1208` linked `CancelAfter(_options.Timeout)` in `EnsureConnectedAsync` | **CONFIRMED, drifted −2** — the `using var cts` / `CancelAfter` / `OpenAsync(cts.Token)` block is at `:1204-1206`; method spans `:1187-1218` | `:1204-1206` |
+| STAB-15 — `S7Driver.cs:1239-1251` `IsS7ConnectionFatal` | **CONFIRMED, drifted +1..3** — the cited range starts inside the xmldoc; the method body is `:1242-1252` (`MarkConnectionDeadIfFatal` at `:1226-1229`). Classifies only `SocketException`/`IOException`/`ObjectDisposedException` (inner-walking) + `PlcException{ErrorCode.ConnectionError}` — no framing types, no cancellation | `:1242-1252` |
+| STAB-15 — `S7Driver.cs:1542` probe bare `catch { }` never marks dead | **CONFIRMED exact** — `catch { /* transport/timeout/exception — treated as Stopped below */ }`; no `MarkConnectionDeadIfFatal` anywhere in `ProbeLoopAsync` (`:1516-1549`) | `:1542` |
+| STAB-8 (S7 part) — `S7Driver.cs:1188-1224` unthrottled per-call reconnect | **CONFIRMED, drifted** — `EnsureConnectedAsync` is `:1187-1218`; no attempt throttle, full `OpenAsync` bounded only by `_options.Timeout` per data call/probe tick while the PLC is down | `:1187-1218` |
+
+Additional load-bearing facts verified for the design:
+
+- **`ReadAsync` per-item loop swallows OCE today** — there is no per-item OCE catch; a mid-PDU
+  cancellation falls into `catch (Exception ex)` (`S7Driver.cs:536-543`) → `MarkConnectionDeadIfFatal`
+  (a no-op for OCE) → `BadCommunicationError`, and the half-read socket is **kept**. `WriteAsync`'s
+  per-item loop *does* have an explicit OCE catch (`:1043-1048`) that rethrows — also without
+  marking the handle dead. Both are STAB-15(b) sites.
+- **The driver throws its own `System.IO.InvalidDataException`** as the decode type-mismatch
+  backstop in `ReinterpretRawValue` (`S7Driver.cs:973-975`) — a *config* fault on a healthy
+  socket. This constrains the classifier design (below): blanket-classifying
+  `InvalidDataException` as connection-fatal would churn a reopen on every read of a
+  mis-authored tag.
+- **S7.Net 0.20.0 framing surface** (verified against the shipped assembly,
+  `~/.nuget/packages/s7netplus/0.20.0`): `TPKTInvalidException`, `TPDUInvalidException`,
+  `WrongNumberOfBytesException` are `public` classes with public parameterless ctors (directly
+  constructible in tests), plus `PlcException` with `ErrorCode.WrongNumberReceivedBytes`. None
+  are classified fatal today.
+- **The probe's own OCE handling is already correct for STAB-14** — `:1541`
+  `catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }` filters on
+  the loop token, so a probe-*timeout* OCE falls to the bare catch and reads as `Stopped`. Its
+  only gap is STAB-15(b): the bare catch never marks the handle dead.
+- **`InitializeAsync` has the same unfiltered connect pattern** (`:186-196`): a connect-timeout
+  OCE escapes to the outer `catch (Exception ex)` → `Faulted` + rethrow — the *health* outcome is
+  right, but the caller receives an OCE for what is a connection failure. Included as a
+  consistency fix (low stakes; init callers treat any throw as init failure).
+- **Test infra:** `S7DriverReconnectTests` (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`)
+  holds the private `FakeS7PlcFactory`/`FakeS7Plc` (Options: `Timeout = 250ms`, probe disabled).
+  `InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.Driver.S7.Tests")` is in place
+  (`ZB.MOM.WW.OtOpcUa.Driver.S7.csproj:26`). The live suite `S7_1500ReconnectTests` is
+  double-gated on `Snap7ServerFixture` + `S7_RECONNECT_BOUNCE_CMD` and — as the report says —
+  **structurally blind to STAB-14**: `docker restart` yields connection-*refused*
+  (`SocketException`, immediate), never the SYN-blackhole connect-*timeout* the bug needs.
+
+---
+
+## Finding 1 — STAB-14 (High, regression) — connect-timeout OCE permanently kills S7 subscription polls
+
+**Restatement.** `EnsureConnectedAsync` bounds the reopen with a linked
+`CancelAfter(_options.Timeout)` CTS (`S7Driver.cs:1204-1206`). Against an
+unreachable-but-not-refusing host (pulled cable, powered-off PLC on a switched network,
+firewall DROP — the classic field outage Critical 3 targeted), `OpenAsync` throws
+`TaskCanceledException` when that internal timer fires. The read/write ensure-wrappers rethrow
+*any* OCE unconditionally (`:488`, `:1000`), so the timeout escapes the driver as OCE. In the
+poll loop, `catch (OperationCanceledException) { return; }` (`:1383`, `:1404`) treats every OCE
+as teardown and exits — no log, no health change, no `HandlePollFailure`. Server-initiated
+reads/writes self-heal on the next call (the Critical-3 fix working as designed), but **every
+subscription poll loop that ticks during the outage is silently and permanently dead until
+redeploy**.
+
+**Verification.** Confirmed at the corrected refs above. Traced end-to-end: poll tick →
+`PollOnceAsync` (`:1458`) → internal `ReadAsync` with the loop's `ct` → `_gate` →
+`EnsureConnectedAsync` → hang → internal `cts.CancelAfter` fires → `TaskCanceledException`
+(an OCE) → wrapper `:488` rethrows (the caller's `ct` is **not** cancelled) → `PollLoopAsync`
+tick catch `:1404` → `return`. Nothing distinguishes this from teardown. Also confirmed the unit
+suite can't currently see it: `FakeS7Plc.OpenAsync` never honours its token (returns
+synchronously or `Task.FromException`), so no existing test produces a timeout-OCE from open.
+
+**Root cause.** The Critical-3 fix introduced an *internal* timeout implemented as cancellation
+(`CancelAfter` on a linked CTS) but let the resulting OCE escape with caller-cancellation
+semantics. Downstream, the bespoke poll loop's bare OCE-catch (a CONV-1 fork divergence —
+`PollGroupEngine` never had this shape) turned "one timed-out reconnect attempt" into "loop
+exits forever". Two mistakes compound: timeout encoded as OCE at the source, and OCE interpreted
+as teardown without consulting the loop token.
+
+**Proposed design.** Fix at **three layers** — the source plus two defensive filters — so no
+single future edit can reintroduce the class:
+
+1. **Source conversion (the root fix):** in `EnsureConnectedAsync`, catch the internal-timeout
+   OCE and rethrow it as `TimeoutException`. A connect timeout *is* a connection failure, not a
+   cancellation; converting at the source makes every caller (read wrapper, write wrapper,
+   probe) handle it via their existing generic degrade paths with zero further changes, and puts
+   a meaningful message in the logs/`_health.LastError` instead of "operation was canceled".
+2. **Wrapper filters (belt):** `:488` and `:1000` become
+   `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }` —
+   caller cancellation still propagates; any *other* OCE (should no longer occur after #1, but
+   e.g. an S7.Net-internal CTS in a future package bump) falls through to the existing
+   `catch (Exception ex)` → whole-batch `BadCommunicationError` + `Degraded`.
+3. **Poll-loop filters (suspenders):** `:1383`, `:1396-1397`, `:1404` become
+   `catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }`. A
+   non-teardown OCE at `:1383`/`:1404` then falls into the existing `catch (Exception ex)` →
+   `HandlePollFailure` (log + Degraded + capped backoff) — the loop lives. (The `Task.Delay`
+   catch at `:1396-1397` can only see `ct`-triggered OCE, so its filter is purely for uniformity.)
+4. **Consistency:** apply the same timeout→`TimeoutException` conversion to the identical
+   connect block in `InitializeAsync` (`:186-196`), so init-time and reconnect-time timeouts
+   surface the same exception type.
+
+*Alternatives considered:*
+- **`when` filters only, no source conversion** (the report's literal recommendation). Works,
+  but leaves "timeout = OCE" encoded in the driver's exception contract: every current and
+  future caller of `EnsureConnectedAsync` must remember the filter idiom (the probe already has
+  it; a fourth caller wouldn't). Converting at the source fixes the taxonomy once. Rejected as
+  *sole* fix; kept as layers 2-3 because defense-in-depth here is nearly free and the filters
+  also cover OCEs born *below* `EnsureConnectedAsync`.
+- **Retry inside `EnsureConnectedAsync` on timeout.** Rejected — retry/backoff policy is exactly
+  the fleet-wide primitive R2-09 owns (STAB-8); baking an ad-hoc retry here would pre-empt it.
+- **Move S7 onto `PollGroupEngine` now (CONV-1)** — the fork's OCE handling disappears with the
+  fork. Right end-state, wrong scope: PollGroupEngine v2 (backoff + onError absorption) is the
+  report's remediation item #5 and is a multi-driver change. This plan's two-line filters are
+  the safe immediate stop-loss; they do not conflict with a later migration.
+
+**Implementation steps** (exact code for each small bit):
+
+1. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs`, `EnsureConnectedAsync` — replace the
+   `try/catch` around `OpenAsync` (`:1202-1212`) with:
+
+   ```csharp
+   try
+   {
+       using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+       cts.CancelAfter(_options.Timeout);
+       await plc.OpenAsync(cts.Token).ConfigureAwait(false);
+   }
+   catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+   {
+       // The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
+       // Surface it as a connection FAILURE (TimeoutException) so callers route it through
+       // their degrade paths: an escaping OCE reads as teardown and permanently killed the
+       // subscription poll loops (STAB-14, the unreachable-host regression in the Crit-3 fix).
+       try { plc.Dispose(); } catch { /* best-effort */ }
+       throw new TimeoutException(
+           $"S7 connect to {_options.Host}:{_options.Port} timed out after " +
+           $"{(int)_options.Timeout.TotalMilliseconds} ms.");
+   }
+   catch
+   {
+       try { plc.Dispose(); } catch { /* best-effort */ }
+       throw;
+   }
+   ```
+
+2. Same file, `ReadAsync` `:488` and `WriteAsync` `:1000` — change
+
+   ```csharp
+   catch (OperationCanceledException) { throw; }
+   ```
+
+   to
+
+   ```csharp
+   // Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the
+   // EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the
+   // batch below, not escape as teardown (STAB-14).
+   catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+   ```
+
+3. Same file, `PollLoopAsync` — add `when (ct.IsCancellationRequested)` to the three OCE catches
+   (`:1383`, `:1396-1397`, `:1404`), e.g. the tick catch:
+
+   ```csharp
+   catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
+   catch (Exception ex)   // now also receives any non-teardown OCE → backoff, not death
+   {
+       consecutiveFailures++;
+       HandlePollFailure(ex, consecutiveFailures, initial: false);
+   }
+   ```
+
+4. Same file, `InitializeAsync` `:184-196` — mirror step 1's `when (!cancellationToken.IsCancellationRequested)`
+   → `TimeoutException` conversion around the initial `OpenAsync`.
+
+**Tests** (all in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`,
+extending the existing fakes — see Task 0; the driver's `Options()` fixture keeps
+`Timeout = 250ms` so hang-until-cancelled opens cost 250 ms each):
+
+- **T1 (the mandated first-and-failing regression test)**
+  `Read_degrades_batch_on_connect_timeout_instead_of_throwing` — connection #1 drops
+  (`NextReadThrows = PlcException(ConnectionError)` → marks dead), factory set to
+  hang-until-cancelled opens; the next `ReadAsync` **currently throws `TaskCanceledException`**;
+  expected: returns a `BadCommunicationError` batch + `Degraded` health, no throw. **FAILS at
+  `f6eaa267`.**
+- **T2** `Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers` — subscribe at
+  100 ms; drop the socket; let ≥1 poll tick hit a hanging reconnect
+  (`OpenHangsUntilCancelledCount = 2`); then restore normal opens; assert a Good `OnDataChange`
+  arrives *after* a Bad one within a 10 s deadline (`TaskCompletionSource` + `WaitAsync`).
+  **FAILS at `f6eaa267`** (loop exits on the first timeout tick; no recovery event ever fires).
+- **T3** `Write_degrades_batch_on_connect_timeout_instead_of_throwing` — T1's shape through
+  `WriteAsync`. **FAILS at `f6eaa267`.**
+- **T4 (pinning)** `Read_still_propagates_caller_cancellation_during_connect` — options
+  `Timeout = 5s`, hang-until-cancelled open, caller CTS cancelled after 50 ms; assert
+  `OperationCanceledException` propagates. **PASSES before and after** — pins the filter
+  direction so the fix can't over-rotate into swallowing real cancellation.
+
+**Effort:** S. **Risk/blast-radius:** Low. Four small catch-clause edits on one file; the happy
+path is untouched. Behavior deltas: (a) connect-timeout now yields `TimeoutException`/Bad-batch
+instead of OCE — strictly the documented intent of the Crit-3 degrade path; (b) poll loops
+survive timeouts with backoff — the regression closed. Callers seeing `TimeoutException` where
+OCE escaped before: only the poll loop (fixed to want this) and `DriverHostActor` dispatch
+(treats both as capability failure).
+
+---
+
+## Finding 2 — STAB-15 (High) — fatal classification misses framing/desync + cancellation-during-I/O; probe never marks dead
+
+**Restatement.** `IsS7ConnectionFatal` (`S7Driver.cs:1242-1252`) classifies only
+`SocketException`/`IOException`/`ObjectDisposedException` (inner-walking) and
+`PlcException{ConnectionError}`. Two gaps: **(a)** a caller-token cancellation mid-PDU (server
+request deadline, or a dispatch-layer Polly timeout now that `CapabilityInvoker` wraps driver
+calls) abandons a half-read ISO-on-TCP response on the socket; OCE is not classified, the handle
+is kept, and the next call reads stale bytes. S7.Net then surfaces framing faults
+(`TPKTInvalidException`, `TPDUInvalidException`, `WrongNumberOfBytesException`,
+`PlcException{WrongNumberReceivedBytes}`) — none classified fatal either — so the single gated
+connection stays **permanently desynchronized** (the Modbus STAB-3 mode rebuilt in S7).
+**(b)** The probe loop swallows every failure (`:1542`) without marking the handle dead, so its
+reconnect-backstop role only works when `TcpClient.Connected` flips false on its own; a
+wire-dead or desynced handle that still claims `Connected` is re-probed and re-fails forever
+(contrast TwinCAT's probe, which force-recycles on failure).
+
+**Verification.** Confirmed. `IsS7ConnectionFatal` body at `:1242-1252` matches the report's
+description exactly. Read path: no per-item OCE catch — OCE lands in `catch (Exception ex)`
+(`:536-543`), `MarkConnectionDeadIfFatal` no-ops, socket kept. Write path: explicit per-item OCE
+rethrow at `:1043-1048`, also without marking. Probe: gate-held body `:1529-1539`, teardown OCE
+filter `:1541`, bare swallow `:1542`; `MarkConnectionDeadIfFatal` is absent from the whole loop.
+S7.Net exception taxonomy verified against the 0.20.0 assembly (all three framing types public,
+public parameterless ctors — unit-constructible). One nuance the report under-specifies: the
+driver *itself* throws `System.IO.InvalidDataException` for a declared-type/address-size
+mismatch (`:973-975`), so the report's suggestion to classify `InvalidDataException` as a
+framing fault must be narrowed (see design).
+
+**Root cause.** The Crit-3 classifier was written from S7.Net's *connection-error* surface
+(socket-level types + `ConnectionError`) without walking its *framing/parsing* surface, and
+without the "cancellation abandons an in-flight PDU on a single serialized stream" insight the
+Modbus STAB-3 analysis had already produced. The probe predates the dead-handle flag and was
+never taught to feed it.
+
+**Proposed design.**
+
+- **(a1) Broaden the classifier to the framing surface.** Add to the inner-walk of
+  `IsS7ConnectionFatal`: `TPKTInvalidException`, `TPDUInvalidException`,
+  `WrongNumberOfBytesException`, and `PlcException{ErrorCode.WrongNumberReceivedBytes}`. A
+  framing violation on ISO-on-TCP means the stream position is untrustworthy; only a reopen
+  recovers. **Deliberately NOT `System.IO.InvalidDataException`:** the driver's own decode
+  backstop (`:973-975`) throws it for a *config* mismatch on a healthy socket — classifying it
+  would churn a full reopen on every read of a mis-authored tag. Residual risk accepted: if some
+  S7.Net path signals desync *only* via `InvalidDataException`, the desynced stream's very next
+  PDU still lands in TPKT/TPDU/WrongNumber territory, which now tears down — self-heal within
+  one extra failed call.
+- **(a2) Cancellation observed during wire I/O marks the handle dead before propagating.** Add a
+  per-item OCE catch in `ReadAsync`'s loop (`_plcDead = true; throw;`) and add
+  `_plcDead = true;` to `WriteAsync`'s existing per-item OCE catch (`:1043-1048`). Both run under
+  `_gate`, honouring `_plcDead`'s documented gate-guarded discipline. Marking is deliberately
+  *unconditional* on OCE here (not routed through the type classifier): whether any bytes moved
+  is unknowable from outside, and the worst case of a false positive is one cheap reopen —
+  versus permanent desync for a false negative. **Behavior change (read path):** caller
+  cancellation mid-read now propagates as OCE instead of being converted to a
+  `BadCommunicationError` snapshot — consistent with `WriteAsync`, with the pre-loop
+  `_gate.WaitAsync(cancellationToken)`, and with "the caller cancelled; nobody consumes the
+  batch".
+- **(b) Probe marks dead under the gate.** Restructure the gate-held probe body with an inner
+  try/catch so classification happens while `_gate` is still held (keeping `_plcDead`
+  gate-guarded — the current outer catch sits outside the gate): teardown OCE rethrows to the
+  outer filter; a probe-*timeout* OCE (deadline fired mid-`ReadStatusAsync` PDU) marks dead
+  unconditionally (same abandonment argument as (a2)); any other exception goes through
+  `MarkConnectionDeadIfFatal`. Net effect: a desynced/hung handle whose `TcpClient.Connected`
+  lies is disposed and reopened by the *next probe tick's* `EnsureConnectedAsync` — the probe
+  becomes the real backstop the Crit-3 design intended (mirrors TwinCAT's probe-recycle).
+
+*Alternatives considered:*
+- **Classify OCE inside `IsS7ConnectionFatal`** instead of at the catch sites — rejected: the
+  classifier is also called for exceptions that *wrap* an OCE thrown before any I/O, and the
+  ensure-wrapper OCE (connect timeout) has no live handle to mark; explicit
+  `_plcDead = true` at the three I/O catch sites is smaller and keeps the classifier a pure
+  type-taxonomy function.
+- **Force-recycle (dispose + null `Plc`) directly in the probe catch** à la TwinCAT — rejected:
+  S7 already has the exactly-once-dispose discipline centralized in `EnsureConnectedAsync` via
+  `_plcDead`; a second dispose site would reintroduce the race the flag was built to prevent.
+- **Blanket-classify `InvalidDataException`** (report's literal text) — rejected as unsafe for
+  the reason in (a1); revisit only if the decode backstop at `:973-975` is retyped first.
+
+**Implementation steps:**
+
+1. `S7Driver.cs`, `IsS7ConnectionFatal` (`:1242-1252`) — extend the walk:
+
+   ```csharp
+   for (Exception? e = ex; e is not null; e = e.InnerException)
+   {
+       if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
+           return true;
+       // ISO-on-TCP framing/desync surface (STAB-15a): the stream position is untrustworthy —
+       // a half-read PDU left by a timeout/cancellation makes every later response mis-frame.
+       // NOTE: deliberately NOT System.IO.InvalidDataException — ReinterpretRawValue throws it
+       // for a declared-type/address-size CONFIG mismatch on a healthy socket.
+       if (e is TPKTInvalidException or TPDUInvalidException or WrongNumberOfBytesException)
+           return true;
+       if (e is PlcException { ErrorCode: ErrorCode.ConnectionError or ErrorCode.WrongNumberReceivedBytes })
+           return true;
+   }
+   return false;
+   ```
+
+   Update the method's xmldoc to match.
+
+2. `ReadAsync` per-item loop — insert **before** `catch (NotSupportedException)` (`:511`):
+
+   ```csharp
+   catch (OperationCanceledException)
+   {
+       // Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
+       // single gated stream — the handle must be reopened, not reused (STAB-15). Mark dead
+       // (we hold _gate) and propagate: the caller cancelled, nobody consumes the batch.
+       _plcDead = true;
+       throw;
+   }
+   ```
+
+3. `WriteAsync` per-item OCE catch (`:1043-1048`) — add `_plcDead = true;` above the `throw;`
+   (extend the comment with the same rationale).
+
+4. `ProbeLoopAsync` (`:1516-1549`) — restructure the gate-held section:
+
+   ```csharp
+   await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
+   try
+   {
+       try
+       {
+           var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false);
+           await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
+           success = true;
+       }
+       catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } // teardown → outer filter
+       catch (OperationCanceledException)
+       {
+           // Probe deadline fired mid-PDU: the CPU-status response may still be in flight,
+           // so the handle can't be reused (half-read ⇒ desync). We hold _gate here.
+           _plcDead = true;
+       }
+       catch (Exception ex)
+       {
+           // STAB-15(b): classify + mark while holding _gate so a wire-dead/desynced handle
+           // whose TcpClient still claims Connected is reopened by the NEXT tick's
+           // EnsureConnectedAsync instead of being re-probed forever.
+           MarkConnectionDeadIfFatal(ex);
+       }
+   }
+   finally { _gate.Release(); }
+   ```
+
+   The existing outer `catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }`
+   (`:1541`) and bare `catch { }` (`:1542`) stay — the outer bare catch now handles only
+   gate-*wait* timeouts (gate contention is not a connection fault; correctly not marked) and the
+   rethrown teardown OCE exits via the outer filter after `finally` releases the gate.
+
+**Tests** (same file/fakes as Finding 1):
+
+- **T5** `Framing_faults_are_classified_connection_fatal_and_reopen` — `[Theory]` over
+  `TPKTInvalidException`, `TPDUInvalidException`, `WrongNumberOfBytesException`,
+  `PlcException(ErrorCode.WrongNumberReceivedBytes)` as `NextReadThrows`; assert the *next* read
+  opens connection #2 and reads Good (`factory.Created.Count == 2`). **FAILS at `f6eaa267`**
+  (count stays 1). Companion negative: `PlcException(ErrorCode.ReadData)` still does **not**
+  reopen (already covered by `Read_does_not_reopen_on_a_data_address_error` — keep green).
+- **T6** `Cancelled_read_marks_handle_dead_and_next_read_reopens` — fake read hangs on its token;
+  caller cancels at 50 ms; assert OCE propagates **and** the next (fresh-token) read creates
+  connection #2. **FAILS at `f6eaa267`** (OCE → `BadCommunicationError`, same handle reused).
+- **T7** `Cancelled_write_marks_handle_dead_and_next_read_reopens` — same via a hanging write
+  (OCE already propagates today; the reopen assertion is what fails). **FAILS at `f6eaa267`.**
+- **T8** `Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens` — probe enabled
+  (`Interval = 50ms`, `Timeout = 250ms`); connection #1's `ReadStatusAsync` throws
+  `SocketException` persistently while `IsConnected` stays true; assert
+  `factory.Created.Count >= 2` and a `Running` host transition within a bounded window (the
+  probe's own next tick reopens via `EnsureConnectedAsync`). **FAILS at `f6eaa267`** (fast path
+  returns the same lying handle forever). Variant in the same test class:
+  **T9** `Probe_timeout_marks_dead` — `ReadStatusAsync` hangs on its token instead of throwing;
+  same assertions. **FAILS at `f6eaa267`.**
+
+**Effort:** S-M (the probe restructure is the only non-trivial edit). **Risk/blast-radius:**
+Low-Medium, all inside `S7Driver.cs`. Deltas: (a) framing faults now cost one reopen instead of
+permanent desync — reopen churn is bounded by the fault actually recurring; (b) read-path caller
+cancellation now propagates (contract-consistent; the only in-repo poll-loop caller handles OCE
+via the Finding-1 filters); (c) probe can now trigger reopens — bounded to one reopen per failed
+probe tick, and only when the failure classifies fatal or times out (a healthy-but-slow PLC that
+*answers* within `Probe.Timeout` is unaffected).
+
+---
+
+## Finding 3 — STAB-8, S7-specific part (Medium, note-only here) — unthrottled per-call reconnect; the R2-09 seam
+
+**Restatement.** While the PLC is down, every data call and every probe tick pays a full TCP
+connect attempt bounded only by `_options.Timeout` (`EnsureConnectedAsync`, `:1187-1218`) — no
+attempt throttle. The poll loop's capped backoff (`:1393-1397`, `ComputeBackoffDelay` `:1447`)
+shields subscriptions only; server-initiated reads/writes and the probe retry unthrottled.
+
+**Verification.** Confirmed at the corrected refs (report's `:1188-1224` drifted; method is
+`:1187-1218`). Note the Finding-1/2 changes slightly *reduce* dead-PLC cost already: poll ticks
+that previously died (stopping their contribution) now back off to `PollBackoffCap` (30 s), and
+timeout-OCEs no longer escape as anomalous exceptions.
+
+**Proposed design — no throttle implemented in this plan.** The connect-attempt throttle is a
+fleet-wide primitive (S7, AbCip, FOCAS, TwinCAT all need it) and belongs to **R2-09**
+(PollGroupEngine v2 / reconnect-backoff consolidation). This plan only **documents the seam**:
+`EnsureConnectedAsync` is the single choke-point every S7 reconnect flows through (data calls +
+probe), already gate-serialized, so R2-09's throttle is a last-failed-attempt timestamp checked
+at the top of its slow path — one field + one early-throw, no structural change. A ``
+paragraph on `EnsureConnectedAsync` names this so R2-09's implementer (and any interim reader
+wondering about dead-PLC connect churn) lands here. *Alternative — implement the throttle now:*
+rejected; a driver-local knob would immediately become fleet debt and prejudge R2-09's shape
+(shared policy vs per-driver options).
+
+**Implementation steps:** extend the `EnsureConnectedAsync` xmldoc `` (`:1180-1184`)
+with the seam note (exact text in Task 10). **Tests:** none (doc-only). **Effort:** S (trivial).
+**Risk:** none.
+
+---
+
+## Verification bar (house thesis: "unit-green ≠ wired")
+
+**Deterministic unit guards** — every behavioral claim above has a fake-factory unit test that
+fails at `f6eaa267` and passes after (T1-T3, T5-T9), plus two pinning tests (T4, existing
+data-address-error test) proving the fixes didn't over-rotate. The fakes drive the *exact*
+production code paths (real `S7Driver`, real gate, real poll/probe loops) — only the wire is
+faked, and the STAB-14 fakes specifically make `OpenAsync` honour its token, which is the
+property the live rig cannot simulate cheaply.
+
+**Live gate (env-gated, operator-run)** — the existing bounce test cannot cover STAB-14/15
+because `docker restart` produces connection-*refused* (instant `SocketException`), never
+connect-*timeout*. The timeout variant needs the SYN-blackhole shape: **packets dropped, not
+rejected**. Design (Task 11), mirroring `S7_RECONNECT_BOUNCE_CMD`'s double-gate:
+
+- New `S7_1500ConnectTimeoutOutageTests` in
+  `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/`, triple-gated on the sim
+  fixture + two env vars:
+  - `S7_TIMEOUT_OUTAGE_START_CMD` — begins dropping traffic to the sim port, e.g.
+    `ssh dohertj2@10.100.0.35 "sudo iptables -I INPUT -p tcp --dport 1102 -j DROP"`
+  - `S7_TIMEOUT_OUTAGE_STOP_CMD` — removes the rule (`-D INPUT …`).
+  Absent vars ⇒ clean `Assert.Skip` (safe offline on macOS; a `finally` always runs the stop
+  command once start succeeded, so the shared sim is never left blackholed).
+- Body: subscribe against the live sim (`lmxopcua-fix up s7 s7_1500` on 10.100.0.35) → Good
+  baseline `OnDataChange` → run start-cmd → observe ≥1 Bad tick (each reconnect attempt now
+  times out rather than refusing) → run stop-cmd → assert a Good `OnDataChange` resumes **on the
+  same subscription** within 90 s. This is precisely the outage class STAB-14 killed; before the
+  fix this test would hang at the recovery assertion.
+- A `docker pause otopcua-python-snap7-s7_1500` / `unpause` pair is an acceptable simpler
+  START/STOP command choice (paused container = ACKless blackhole for established flows and
+  unanswered SYNs for new connects) — document both in the test's remarks; the env-var design
+  deliberately leaves the mechanism to the operator.
+
+Run recipe for the operator (also in the test xmldoc):
+
+```bash
+lmxopcua-fix up s7 s7_1500
+export S7_TIMEOUT_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-python-snap7-s7_1500"'
+export S7_TIMEOUT_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-python-snap7-s7_1500"'
+dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "Category=Reconnect"
+```
+
+---
+
+## Task breakdown
+
+TDD discipline throughout: the failing test lands (and is *observed* failing) before the code it
+guards. Filter strings assume the repo root. Tasks 0-5 are Finding 1 (STAB-14); 6-9 are
+Finding 2 (STAB-15); 10-12 close out. **Task 1 is the mandated connect-timeout regression test
+and must be run and seen RED before any driver edit.**
+
+### Task 0 — Extend the reconnect-test fakes with token-honouring hang modes
+
+**Classification:** trivial
+**Estimated implement time:** 5 min
+**Parallelizable with:** nothing (everything depends on it)
+**Files:**
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+
+Steps:
+1. In `FakeS7Plc`: add `public bool OpenHangsUntilCancelled { get; set; }`,
+   `public bool ReadHangsUntilCancelled { get; set; }`, `public bool WriteHangsUntilCancelled { get; set; }`,
+   `public Exception? ReadStatusThrows { get; set; }` (persistent, not one-shot),
+   `public bool ReadStatusHangsUntilCancelled { get; set; }`. In each corresponding method, when
+   the flag is set: `await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);`
+   (throws `TaskCanceledException` when the token fires) / `throw ReadStatusThrows` — placed
+   *before* the existing one-shot `*ThrowsOnce` handling. Convert the affected methods to
+   `async Task`.
+2. In `FakeS7PlcFactory`: add `public int OpenHangsUntilCancelledCount { get; set; }` (each
+   `Create` decrements it and sets `plc.OpenHangsUntilCancelled = true` while positive) and
+   `public Exception? ReadStatusThrowsForNewConnections { get; set; }` (copied onto each created
+   plc; lets T8 arm connection #1 only by clearing it after init).
+3. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"` — expected: **PASS** (existing 4 tests unaffected; compile-only change).
+4. Commit: `test(s7): extend reconnect fakes with token-honouring hang + probe-fault modes (R2-01 task 0)`
+
+### Task 1 — RED: the connect-timeout regression tests (T1 + T3) — MUST FAIL on current code
+
+**Classification:** small
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:**
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+
+Steps:
+1. Write `Read_degrades_batch_on_connect_timeout_instead_of_throwing` (T1, Finding 1 Tests) and
+   `Write_degrades_batch_on_connect_timeout_instead_of_throwing` (T3): drop connection #1 via
+   `NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped")` + one read to mark
+   dead; set `factory.OpenHangsUntilCancelledCount = int.MaxValue`; assert the next
+   `ReadAsync`/`WriteAsync` does **not** throw, returns non-zero statuses, and health is
+   `Degraded`.
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~connect_timeout"` — expected: **FAIL ×2** (`TaskCanceledException` escapes `ReadAsync`/`WriteAsync`). Record the failure output.
+3. No commit yet (red tests commit together with Task 2's green).
+
+### Task 2 — RED: the poll-loop-survival test (T2) + caller-cancel pinning test (T4)
+
+**Classification:** small
+**Estimated implement time:** 5 min
+**Parallelizable with:** — (same file as Task 1; sequence after it)
+**Files:**
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+
+Steps:
+1. Write `Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers` (T2): subscribe
+   `["W0"]` at 100 ms; hook `OnDataChange` — set `sawBad` on `e.Snapshot.StatusCode != 0u`,
+   complete a `TaskCompletionSource` on the first Good snapshot after `sawBad`; drop connection
+   #1; `OpenHangsUntilCancelledCount = 2`; await the TCS with `WaitAsync(TimeSpan.FromSeconds(10))`.
+2. Write `Read_still_propagates_caller_cancellation_during_connect` (T4): options with
+   `Timeout = TimeSpan.FromSeconds(5)`; mark dead; `OpenHangsUntilCancelledCount = 1`; caller
+   `CancellationTokenSource` with `CancelAfter(50)`; `Should.ThrowAsync`.
+3. Run the two new tests — expected: **T2 FAIL** (10 s timeout — the loop died on the timeout
+   tick), **T4 PASS** (pins existing correct behavior).
+4. No commit yet.
+
+### Task 3 — GREEN: `EnsureConnectedAsync` + `InitializeAsync` timeout→`TimeoutException` conversion
+
+**Classification:** standard
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`EnsureConnectedAsync` `:1202-1212`; `InitializeAsync` `:184-196`)
+
+Steps:
+1. Apply Finding 1 implementation step 1 (exact code above) and step 4 (the `InitializeAsync`
+   mirror — filter on `!cancellationToken.IsCancellationRequested`).
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"` — expected: **ALL PASS** (T1/T2/T3 go green; T4 + the four pre-existing tests stay green).
+3. Commit: `fix(s7): surface connect-timeout as TimeoutException, not OCE — poll loops no longer die on unreachable-host outages (STAB-14, R2-01)`
+
+### Task 4 — Defensive `when` filters: ensure-wrappers + poll-loop catches
+
+**Classification:** small
+**Estimated implement time:** 5 min
+**Parallelizable with:** Task 5
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`:488`, `:1000`, `:1383`, `:1396-1397`, `:1404`)
+
+Steps:
+1. Apply Finding 1 implementation steps 2 and 3 (exact code above). These are belt-and-suspenders
+   behind Task 3 — no reachable path produces a non-teardown OCE any more, so no new red test
+   exists; the deterministic guards are T2 (loop survival) and T4 (cancellation direction), which
+   must both stay green.
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests` — expected: **PASS** (whole project).
+3. Commit: `fix(s7): filter OCE catches on the owning token in read/write wrappers + poll loop (STAB-14 defense-in-depth, R2-01)`
+
+### Task 5 — Full S7 unit-suite regression sweep for Finding 1
+
+**Classification:** trivial
+**Estimated implement time:** 3 min
+**Parallelizable with:** Task 4 (can be its verify step); listed separately as the finding-close gate
+**Files:** none (test run only)
+
+Steps:
+1. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests` and
+   `dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests` — expected: **PASS**.
+2. No commit (gate only; if anything fails, fix within Tasks 3-4 scope before proceeding).
+
+### Task 6 — RED: framing-fault classification tests (T5)
+
+**Classification:** small
+**Estimated implement time:** 5 min
+**Parallelizable with:** Task 8 (different finding sub-part, same file — prefer sequencing)
+**Files:**
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+
+Steps:
+1. Write `[Theory]` `Framing_faults_are_classified_connection_fatal_and_reopen` with
+   `[MemberData]`/switch over four cases: `new TPKTInvalidException()`,
+   `new TPDUInvalidException()`, `new WrongNumberOfBytesException()`,
+   `new PlcException(ErrorCode.WrongNumberReceivedBytes, "short read")` (all public ctors,
+   verified against S7netplus 0.20.0). Shape: set as `NextReadThrows`; first read → non-zero
+   status, `Created.Count` still 1; second read → **Good** and `Created.Count == 2`,
+   `Created[0].Disposed` true.
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~Framing_faults"` — expected: **FAIL ×4** (`Created.Count` stays 1).
+3. No commit yet.
+
+### Task 7 — GREEN: broaden `IsS7ConnectionFatal`
+
+**Classification:** small
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`IsS7ConnectionFatal` `:1242-1252` + its xmldoc `:1231-1241`)
+
+Steps:
+1. Apply Finding 2 implementation step 1 (exact code above), including the NOT-`InvalidDataException`
+   comment and the xmldoc update naming the framing surface.
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"` — expected: **PASS** (T5 ×4 green; `Read_does_not_reopen_on_a_data_address_error` still green — the negative control).
+3. Commit: `fix(s7): classify ISO-on-TCP framing/desync faults as connection-fatal (STAB-15a, R2-01)`
+
+### Task 8 — RED→GREEN: cancellation-during-I/O marks the handle dead (T6 + T7)
+
+**Classification:** standard
+**Estimated implement time:** 5 min (tests) + 5 min (fix) — two sittings of ≤5 min
+**Parallelizable with:** —
+**Files:**
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`ReadAsync` per-item loop, insert before `:511`; `WriteAsync` OCE catch `:1043-1048`)
+
+Steps:
+1. Write `Cancelled_read_marks_handle_dead_and_next_read_reopens` (T6): `Created[0].ReadHangsUntilCancelled = true`;
+   caller CTS `CancelAfter(50)`; assert `Should.ThrowAsync` on the read;
+   then a fresh-token read → Good, `Created.Count == 2`. Write
+   `Cancelled_write_marks_handle_dead_and_next_read_reopens` (T7) with `WriteHangsUntilCancelled`.
+2. Run `--filter "FullyQualifiedName~marks_handle_dead"` — expected: **FAIL ×2** (T6 additionally
+   fails its throw assertion today — the read path converts OCE to a Bad snapshot; T7 fails the
+   reopen assertion).
+3. Apply Finding 2 implementation steps 2 and 3 (exact code above).
+4. Re-run — expected: **PASS ×2**; then the whole `S7DriverReconnectTests` class — **PASS**.
+5. Commit: `fix(s7): cancellation observed mid-PDU marks the connection dead before propagating (STAB-15a, R2-01)`
+
+### Task 9 — RED→GREEN: probe marks the handle dead (T8 + T9)
+
+**Classification:** standard
+**Estimated implement time:** 5 min (tests) + 5 min (fix) — two sittings of ≤5 min
+**Parallelizable with:** —
+**Files:**
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`ProbeLoopAsync` `:1516-1549`)
+
+Steps:
+1. Write `Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens` (T8): probe-enabled
+   options (`Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(250) }`);
+   after init, arm connection #1 with persistent `ReadStatusThrows = new SocketException()`
+   (leave later connections healthy); poll `factory.Created.Count` under a 5 s deadline until
+   `>= 2`, then assert a subsequent `GetHostStatuses()[0].State == HostState.Running` within a
+   further bounded window. Write `Probe_timeout_marks_dead` (T9): same shape with
+   `ReadStatusHangsUntilCancelled = true` on connection #1.
+2. Run `--filter "FullyQualifiedName~Probe_"` (scoped to the reconnect class) — expected:
+   **FAIL ×2** (`Created.Count` pinned at 1 — the lying handle is re-probed forever).
+3. Apply Finding 2 implementation step 4 (exact probe restructure above).
+4. Re-run — expected: **PASS ×2**; whole test project — **PASS**.
+5. Commit: `fix(s7): probe failures mark the handle dead under the gate so the next tick reopens (STAB-15b, R2-01)`
+
+### Task 10 — Doc: the R2-09 connect-throttle seam note on `EnsureConnectedAsync`
+
+**Classification:** trivial
+**Estimated implement time:** 3 min
+**Parallelizable with:** Tasks 6-9
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`EnsureConnectedAsync` `` `:1180-1184`)
+
+Steps:
+1. Append to the ``:
+   `While the PLC is down every data call and probe tick pays a full connect attempt bounded only by _options.Timeout (STAB-8). This method is the single choke-point all S7 reconnects flow through (already gate-serialized), so the fleet-wide connect-attempt throttle planned in R2-09 plugs in here: a last-failed-attempt timestamp checked at the top of the slow path. Deliberately NOT implemented driver-locally — see archreview/plans/R2-01-s7-fault-hardening-plan.md Finding 3.`
+2. Build-only check (`dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7`) — expected: **PASS**.
+3. Commit: `docs(s7): mark EnsureConnectedAsync as the R2-09 connect-throttle seam (STAB-8 note, R2-01)`
+
+### Task 11 — Env-gated live connect-timeout outage test (the STAB-14 live gate)
+
+**Classification:** standard
+**Estimated implement time:** 5 min (authoring; the live run is a separate operator action)
+**Parallelizable with:** Tasks 6-10
+**Files:**
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ConnectTimeoutOutageTests.cs`
+
+Steps:
+1. Author per the Verification-bar design: `[Collection(Snap7ServerCollection.Name)]`,
+   `[Trait("Category","Integration")]`, `[Trait("Category","Reconnect")]`,
+   `[Trait("Device","S7_1500")]`; triple-gated skip on `sim.SkipReason` +
+   `S7_TIMEOUT_OUTAGE_START_CMD` + `S7_TIMEOUT_OUTAGE_STOP_CMD`; reuse
+   `S7_1500ReconnectTests.RunBounceCommandAsync`'s shell-exec shape (copy the private helper —
+   or hoist it to the fixture if trivial); `try/finally` guarantees STOP runs once START
+   succeeded. Body: subscribe → Good baseline → START (blackhole) → observe ≥1 Bad
+   `OnDataChange` → STOP → await a Good `OnDataChange` on the same subscription within 90 s.
+   Xmldoc carries the operator recipe (docker pause/unpause and iptables DROP variants) and the
+   explicit rationale: `docker restart` = connection-refused; this test exists because STAB-14
+   only fires on connect-*timeout*.
+2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage"` locally (no env vars) — expected: **SKIP (cleanly)**.
+3. Commit: `test(s7): env-gated live connect-timeout outage test — the STAB-14 outage class the bounce test cannot produce (R2-01)`
+4. Note for the operator (not a CI step): run the recipe from the Verification-bar section
+   against the 10.100.0.35 fixture (`lmxopcua-fix up s7 s7_1500`) before trusting the fix live.
+
+### Task 12 — Close-out: whole-solution S7-adjacent sweep + bookkeeping
+
+**Classification:** trivial
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:**
+- Modify: `archreview/plans/STATUS.md` (mark R2-01 done, note the live-gate env vars)
+- Modify: `archreview/05-protocol-drivers.md` (prior-finding table: STAB-14/15 → FIXED with commit refs; STAB-8 row: note the documented S7 seam)
+
+Steps:
+1. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests` and a solution build — expected: **PASS**.
+2. Update the two archreview files (working-tree convention — these carry uncommitted re-review
+   state; follow whatever commit discipline the archreview docs branch is using at execution time).
+3. Commit: `docs(archreview): R2-01 S7 fault-path hardening complete — STAB-14/15 fixed, STAB-8 seam documented`
+
+---
+
+## Effort & risk summary
+
+| Finding | Effort | Risk / blast radius |
+|---|---|---|
+| STAB-14 (Tasks 0-5) | **S** | Low — four catch-clause edits, one file; regression guarded by T1-T4 + live gate |
+| STAB-15 (Tasks 6-9) | **S-M** | Low-Medium — classifier + three catch sites + probe restructure, one file; guarded by T5-T9 + negative controls |
+| STAB-8 note (Task 10) | **S** (trivial) | None — doc only |
+| Live gate + close-out (Tasks 11-12) | **S** | None locally (env-gated skip); live run is operator-driven |
+| **Overall** | **S-M** (~2-3 h implementer wall-time) | Single-file production change (`S7Driver.cs`); no contract/DI/schema changes |
diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json
new file mode 100644
index 00000000..eef3b8a8
--- /dev/null
+++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json
@@ -0,0 +1,84 @@
+{
+  "planPath": "archreview/plans/R2-01-s7-fault-hardening-plan.md",
+  "tasks": [
+    {
+      "id": 0,
+      "subject": "Extend reconnect-test fakes with token-honouring hang + probe-fault modes (FakeS7Plc/FakeS7PlcFactory)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": 1,
+      "subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267",
+      "status": "pending",
+      "blockedBy": [0]
+    },
+    {
+      "id": 2,
+      "subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
+      "status": "pending",
+      "blockedBy": [0, 1]
+    },
+    {
+      "id": 3,
+      "subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
+      "status": "pending",
+      "blockedBy": [1, 2]
+    },
+    {
+      "id": 4,
+      "subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
+      "status": "pending",
+      "blockedBy": [3]
+    },
+    {
+      "id": 5,
+      "subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
+      "status": "pending",
+      "blockedBy": [4]
+    },
+    {
+      "id": 6,
+      "subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL",
+      "status": "pending",
+      "blockedBy": [0]
+    },
+    {
+      "id": 7,
+      "subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
+      "status": "pending",
+      "blockedBy": [6]
+    },
+    {
+      "id": 8,
+      "subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
+      "status": "pending",
+      "blockedBy": [7]
+    },
+    {
+      "id": 9,
+      "subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)",
+      "status": "pending",
+      "blockedBy": [8]
+    },
+    {
+      "id": 10,
+      "subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync  (STAB-8 S7 part, note-only)",
+      "status": "pending",
+      "blockedBy": [3]
+    },
+    {
+      "id": 11,
+      "subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)",
+      "status": "pending",
+      "blockedBy": [5]
+    },
+    {
+      "id": 12,
+      "subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)",
+      "status": "pending",
+      "blockedBy": [5, 7, 8, 9, 10, 11]
+    }
+  ],
+  "lastUpdated": "2026-07-12"
+}
diff --git a/archreview/plans/R2-02-resilience-config-hardening-plan.md b/archreview/plans/R2-02-resilience-config-hardening-plan.md
new file mode 100644
index 00000000..77c58567
--- /dev/null
+++ b/archreview/plans/R2-02-resilience-config-hardening-plan.md
@@ -0,0 +1,353 @@
+# Design + Implementation Plan — R2-02 ResilienceConfig Hardening Pass
+
+- **Source reports:** `archreview/01-core-composition.md` (S-6, U-6, S-7, S-8), `archreview/03-server-runtime.md` (S12 = 01/S-8, S13 = 01/S-7), `archreview/04-adminui.md` (C-7)
+- **Review commit:** `f6eaa267` (2026-07-12 re-review) · **Plan verified against tree at:** `f6eaa267` (master, working-tree archreview updates)
+- **Scope:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/` (parser, pipeline builder, invoker, factory), `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/` (seam docs, new ranges class), `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (write dispatch), `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/` (form model + section), plus their test suites.
+- **This is action item #2 in `archreview/00-OVERALL.md`:** *"ResilienceConfig hardening pass: range-validate in parser (clamp + diagnostic, never brick) and AdminUI form; wire or delete the bulkhead knob; read `WriteIdempotentAttribute` (or default writes non-idempotent); fix lossy form round-trip."* Effort Small-Medium, Impact High — closes six of the nine notable new findings from the 2026-07-12 round (theme #6: "the resilience seam is the new second-class citizen").
+- **Design history honored:** [`FOLLOWUP-10`](FOLLOWUP-10-resilience-dispatch-gap.md) + [`FOLLOWUP-13`](FOLLOWUP-13-resilience-config-artifact.md). The Runtime is **deliberately Polly-free** (references `Core.Abstractions`, never `Core`); the invoker seam (`IDriverCapabilityInvoker`/`IDriverCapabilityInvokerFactory`) lives in `Core.Abstractions`; the null objects are genuine pass-throughs that keep dispatch tests byte-identical. Nothing in this plan touches the seam's *interface* shapes, the pass-through nulls, the per-instance `Invalidate` pairing, or the respawn-on-change semantics — they are extended, not reworked. The parser's contract stays **never throw: clamp + diagnostic, don't reject**.
+
+## Verification summary
+
+Every anchor in the tasking was opened at `f6eaa267` and checked against the current source. **All five findings CONFIRMED.** Two anchors carry minor line drift; two findings were sharpened by verification beyond what the reports state:
+
+| Finding | Anchor check | Status |
+|---|---|---|
+| 01/S-6 | `DriverResilienceOptionsParser.cs:91-95` (merge without range checks — actual merge :90-94), `DriverResiliencePipelineBuilder.cs:111-114` (`AddTimeout(TimeSpan.FromSeconds(policy.TimeoutSeconds))`), `ResilienceFormModel.cs:32-37` (bare `int?`, no validation anywhere in the model) | **CONFIRMED** — and **sharpened**: two brick vectors, not one (below) |
+| 01/U-6 | `DriverResiliencePipelineBuilder.cs:99-177` (`Build` actually spans :99-176 — off by one), `DriverResilienceOptions.cs:23-25` (**drift:** `BulkheadMaxConcurrent` at :23 but `BulkheadMaxQueue` at :29; doc lines between) | **CONFIRMED** — no `AddConcurrencyLimiter`/rate-limiter anywhere in `src/`; `Polly.RateLimiting` not in `Directory.Packages.props`; and **strengthened** for the delete option (below) |
+| 01/S-8 = 03/S12 | `DriverInstanceActor.cs:597-601` (`isIdempotent: true` at :599), `CapabilityInvoker.cs:117-131` (the `!isIdempotent` arm) | **CONFIRMED** — `grep -rn WriteIdempotentAttribute` hits only its own file + two doc refs in `DriverCapability.cs`; zero readers. Tier Write/AlarmAcknowledge defaults are `RetryCount: 0` on all three tiers (`DriverResilienceOptions.cs:83,88,94,99,105,110`), so behavior-neutral today. **Sharpened:** the `!isIdempotent` arm also skips the tracker's `RecordCallStart/Complete` accounting (compare :70-81 vs :117-135) |
+| 04/C-7 | `ResilienceFormModel.cs:52-74` (`FromJson` catches `JsonException` → empty form at :59, with the "raw view (next task)" comment), `DriverResilienceSection.razor:37-40` (raw pane renders `_m.ToJson()`, not stored text), `:60-66` (`EmitAsync` replaces the bound value) | **CONFIRMED** — first keystroke after a malformed/hand-edited stored config silently overwrites it. The house preserve-unknown pattern exists next door (`Uns/TagEditors/ModbusTagConfigModel.cs` — `JsonObject _bag` + `TagConfigJson` helpers) |
+| 01/S-7 = 03/S13 | `DriverCapabilityInvokerFactory.cs:71` (`Invalidate` inside `Create`, before old child terminates), `DriverResiliencePipelineBuilder.cs:75-79` (`GetOrAdd` — options-blind on a hit; actual :76-79), `DriverHostActor.cs:1278-1280` (`ToStop` → `Context.Stop` (async) then `ToSpawn` in one handler) | **CONFIRMED** — an in-flight old-child handler can re-cache a stale-options pipeline after the new spawn's `Invalidate`, and the cache serves it indefinitely |
+
+**Sharpening 1 — S-6 has two brick vectors, verified against Polly.Core 8.6.6 (the pinned version):**
+
+- `TimeoutStrategyOptions.Timeout` is range-validated to **[10 ms, 24 h]** at pipeline build. `"timeoutSeconds": 0` (→ `TimeSpan.Zero`), any negative, or any value > 86 400 throws `ValidationException` inside the `GetOrAdd` factory — on the first call and **every** subsequent call (a throwing factory caches nothing).
+- `CircuitBreakerStrategyOptions.MinimumThroughput` (which `Build` sets to `policy.BreakerFailureThreshold`, :146) is validated to **≥ 2**. So `"breakerFailureThreshold": 1` — a perfectly plausible operator intent ("open on first failure") — bricks the same way. The report names only the timeout vector; the fix must cover both.
+- `RetryStrategyOptions.MaxRetryAttempts` accepts [1, int.MaxValue] and `Build` guards `RetryCount > 0` (:116), so retry cannot brick — but an absurd retryCount (e.g. `2000000000`) is an operational hazard (each attempt backs off up to 5 s) and gets clamped anyway.
+
+**Sharpening 2 — U-6's "wire" option is provably inert in production dispatch.** `DriverInstanceActor` is a `ReceiveActor` whose capability handlers are `ReceiveAsync` (:406-457) — the mailbox suspends for the whole handler (the source says so itself at :859, :881). At most **one** capability call is ever in flight per driver child, so a concurrency limiter with any permit ≥ 1 can never queue or reject there. The only fan-out surface that could drive concurrency through one invoker, `AlarmSurfaceInvoker`, has **zero production references** (grep: only its own file + analyzer doc remarks). Wiring the bulkhead would add a new package (`Polly.RateLimiting`) for a strategy that cannot engage — manufacturing a fresh instance of the exact dead-knob genre this pass exists to close. This decides wire-vs-delete (below).
+
+**Drift corrections for the reports** (cosmetic; fold into the next report refresh):
+- `01-core-composition.md` U-6 anchor `DriverResilienceOptions.cs:23-25` → the two properties sit at `:23` and `:29`.
+- `01-core-composition.md` U-6 anchor `DriverResiliencePipelineBuilder.cs:99-177` → `Build` is `:99-176`.
+- All other cited lines are exact at `f6eaa267`.
+
+---
+
+## Priority ordering
+
+1. **S-6** (High) — the operator-authorable permanent driver-brick; the S-6 brick-repro test is the pass's first commit and must fail on current code.
+2. **S-8 = 03/S12** (Medium) — the write-retry footgun #13 armed; do second because its parser change shares files with S-6's.
+3. **U-6** (Medium) — delete the dead bulkhead knob; after S-6/S-8 so the parser churn is serialized.
+4. **C-7** (Medium) — non-lossy form round-trip; after U-6 so the form's field set is final.
+5. **S-7 = 03/S13** (Low) — the stale-pipeline race; last, builds on the S-6 builder changes.
+
+---
+
+## 1. S-6 — HIGH — Out-of-range ResilienceConfig values brick every capability call, violating the parser's never-brick contract
+
+**Restatement:** `DriverResilienceOptionsParser.ParseOrDefaults` defends against malformed JSON, unknown capability names, and a misapplied Tier-C recycle interval — but merges `TimeoutSeconds`/`RetryCount`/`BreakerFailureThreshold` overrides with **no range validation** (`DriverResilienceOptionsParser.cs:90-94`). Polly 8.6.6 range-validates at pipeline build, so `"timeoutSeconds": 0` (or `"breakerFailureThreshold": 1`) makes `DriverResiliencePipelineBuilder.Build` throw `ValidationException` inside the `GetOrAdd` factory on **every** capability call for that (instance, host, capability). Subscribe, discover, write, and alarm-ack all fail persistently while the driver is healthy — precisely the outcome the parser's own remarks (`:30-33`, "a misconfigured ResilienceConfig should never brick a working driver") promise to prevent. Operator-reachable: the AdminUI `ResilienceFormModel` binds all three as bare `int?` with no validation (`ResilienceFormModel.cs:32-37`), and #13's respawn-on-change makes the bad config take effect on the next deploy.
+
+**Verification:** Confirmed + sharpened (see summary): two brick vectors — timeout out of [1 s, 24 h] and breaker threshold == 1 (Polly `MinimumThroughput` floor is 2). Retry cannot brick (builder guards `> 0`; Polly accepts up to int.MaxValue) but is clamped for sanity. The throw site is `_pipelines.GetOrAdd` (`DriverResiliencePipelineBuilder.cs:77-79`) invoked from `CapabilityInvoker.ResolvePipeline` (`CapabilityInvoker.cs:140-141`) — i.e. **inside** the actor's try/catch arms, so each call fails "cleanly" (WriteAttributeResult(false, …), etc.) but *forever*.
+
+**Root cause:** The parser validates *shape* (JSON well-formedness, enum membership) but not *domain* (Polly's documented ranges); nothing between the JSON column and `ResiliencePipelineBuilder.Build()` knows the ranges, and the one component that enforces them (Polly) does so by throwing at the worst possible seam — inside a memoizing factory on the dispatch path.
+
+**Proposed design — clamp + diagnostic at the parser (primary), belt-and-braces clamp at the builder (secondary), mirrored validation messaging in the AdminUI form (tertiary).**
+
+- **Clamp-vs-reject:** *reject* (refuse the whole override, or fail the deploy) was considered and **rejected** — the parser's contract is explicit ("callers … should NOT fail driver startup"), draft-time validation can't cover the non-AdminUI write paths (the JSON column is writable by SQL, import, future APIs), and rejecting a whole policy for one bad field discards the operator's valid fields. Clamping each field independently to the nearest legal value (or the tier default when the value is nonsense), with the existing `parseDiagnostic` out-parameter naming exactly what was clamped, preserves maximum operator intent and can never brick. The factory already logs the diagnostic as a warning (`DriverCapabilityInvokerFactory.cs:61-66`) — the observability channel exists.
+- **Single source of truth for the bounds:** a new `ResiliencePolicyRanges` static class in **`Core.Abstractions`** (not `Core`) so all three layers can share it: the parser and builder (Core references Abstractions) and `ResilienceFormModel` (AdminUI references Runtime → transitively Core.Abstractions; AdminUI deliberately does **not** reference Core — verified in `ZB.MOM.WW.OtOpcUa.AdminUI.csproj:20-26`). Constants:
+
+  ```csharp
+  namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+  /// 
+  ///     Legal ranges for per-capability resilience policy values. Single source of truth shared by
+  ///     DriverResilienceOptionsParser (clamp + diagnostic), DriverResiliencePipelineBuilder (last-resort
+  ///     clamp so a pipeline build can never throw Polly's ValidationException), and the AdminUI
+  ///     ResilienceFormModel (authoring-time validation messages). Derived from Polly.Core 8.6.6's
+  ///     option validation: TimeoutStrategyOptions.Timeout ∈ [10 ms, 24 h];
+  ///     CircuitBreakerStrategyOptions.MinimumThroughput ≥ 2.
+  /// 
+  public static class ResiliencePolicyRanges
+  {
+      /// Minimum per-call timeout. Whole-second granularity puts the floor at 1 s (Polly's is 10 ms).
+      public const int MinTimeoutSeconds = 1;
+      /// Maximum per-call timeout — Polly's TimeoutStrategyOptions ceiling (24 h).
+      public const int MaxTimeoutSeconds = 86_400;
+      /// Retry floor; 0 = no retry strategy is added to the pipeline.
+      public const int MinRetryCount = 0;
+      /// Operational retry cap (Polly itself allows int.MaxValue; 100 exponential-backoff attempts ≈ minutes).
+      public const int MaxRetryCount = 100;
+      /// Breaker floor; 0 = no breaker strategy is added (the Tier C posture).
+      public const int MinBreakerFailureThreshold = 0;
+      /// Polly's MinimumThroughput floor — an enabled breaker needs a threshold of at least 2.
+      public const int MinEnabledBreakerFailureThreshold = 2;
+      /// Sanity cap on the breaker threshold (Polly has no ceiling; beyond this it can never trip in the 30 s window).
+      public const int MaxBreakerFailureThreshold = 10_000;
+  }
+  ```
+
+- **Parser clamp semantics** (per merged override field, in `ParseOrDefaults` after the null-coalesce at `:91-94`; diagnostics use the existing `??=`-first-wins channel — upgrade it to *append* with `"; "` so multiple clamps all surface, matching the "one diagnostic string" signature):
+  - `timeoutSeconds ≤ 0` → **that capability's tier default** + diagnostic (a non-positive timeout is nonsense, not an extreme — snapping to 1 s could be a 30× surprise on a Discover policy). `> MaxTimeoutSeconds` → clamp to max + diagnostic.
+  - `retryCount < 0` → 0 + diagnostic. `> MaxRetryCount` → 100 + diagnostic.
+  - `breakerFailureThreshold < 0` → 0 (breaker off) + diagnostic. `== 1` → `MinEnabledBreakerFailureThreshold` (2) + diagnostic (closest to the operator's "open fast" intent). `> MaxBreakerFailureThreshold` → clamp + diagnostic.
+- **Builder belt-and-braces** (in `Build`, `DriverResiliencePipelineBuilder.cs:108`): clamp the resolved policy before constructing strategy options — `Math.Clamp(policy.TimeoutSeconds, 1, 86_400)`, `Math.Min(policy.RetryCount, 100)` under the existing `> 0` guard, `Math.Max(2, …)` for `MinimumThroughput` under the existing `> 0` guard. Rationale: `Build` is the *throw site*, and `DriverResilienceOptions` is a public record constructible by paths that never traverse the parser (tests today; any future caller). This makes "a pipeline build never throws `ValidationException`" a builder invariant, directly testable. Cost: three integer ops once per pipeline build (not per call). *Alternative rejected:* catching `ValidationException` around `GetOrAdd` and falling back to a tier-default pipeline — heavier, hides the misconfiguration deeper, and still leaves the invariant unproven for direct `Build` callers.
+- **AdminUI form** (`ResilienceFormModel`): add a `Validate()` returning `IReadOnlyList` of range-violation messages (using `ResiliencePolicyRanges`, naming the capability + field + legal range — mirroring the driver tag editors' `Validate()` idiom), and render the messages in `DriverResilienceSection.razor` as an inline warning block under the table. The form still emits (the parser clamp is the authoritative guard; blocking save would require touching all 8 driver pages' save flows — out of scope, noted for 04/C-1's authz/gating pass). This is authoring-time *feedback*, not the enforcement layer — defense in depth stays in the parser, exactly as the report recommends.
+
+**Implementation steps:**
+1. **Brick-repro test first (must fail at `f6eaa267`):** new `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/ResilienceConfigBrickTests.cs` — parse `{"capabilityPolicies":{"Read":{"timeoutSeconds":0}}}` (Tier A), `GetOrCreate` + execute a trivial callSite through the pipeline; assert it succeeds and repeat for `{"breakerFailureThreshold":1}`. On current code both throw `ValidationException`.
+2. Add `ResiliencePolicyRanges.cs` to `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
+3. Parser: extract a `private static CapabilityPolicy ClampPolicy(DriverCapability cap, CapabilityPolicy merged, CapabilityPolicy tierDefault, ref string? diagnostic)` applied inside the merge loop; switch the diagnostic channel from `??=` to an append helper.
+4. Builder: clamp in `Build` as above; keep the `RetryCount > 0` / `BreakerFailureThreshold > 0` enable-guards evaluating the *clamped* values.
+5. AdminUI: `ResilienceFormModel.Validate()` + warning block in `DriverResilienceSection.razor`.
+
+**Tests:**
+- `ResilienceConfigBrickTests` (step 1) goes green — the load-bearing regression guard.
+- `DriverResilienceOptionsParserTests`: one test per clamp rule (`TimeoutSeconds_Zero_FallsBackToTierDefault_WithDiagnostic`, `TimeoutSeconds_AbovePollyMax_Clamped`, `RetryCount_Negative_ClampedToZero`, `RetryCount_AboveCap_ClampedTo100`, `BreakerThreshold_One_ClampedToTwo`, `BreakerThreshold_Negative_DisablesBreaker`, `MultipleClamps_AllSurfaceInDiagnostic`); regression: in-range overrides pass through untouched with null diagnostic.
+- `DriverResiliencePipelineBuilderTests`: `Build_NeverThrows_ForHostileDirectOptions` — loop a matrix of hostile `CapabilityPolicy` values (`int.MinValue`, -1, 0, 1, `int.MaxValue`) through `GetOrCreate` + execute; asserts the builder invariant independently of the parser.
+- **Production-wiring assertion (unit-green ≠ wired):** `DriverCapabilityInvokerFactoryTests.Create_with_out_of_range_config_yields_working_invoker` — the exact prod path `DriverHostActor.SpawnChild` exercises (`factory.Create(id, type, hostileJson)` → `invoker.ExecuteAsync` succeeds + the clamp diagnostic is logged). Prod DI binding of this factory is already pinned by `ResilienceInvokerFactoryRegistrationTests` (Host.IntegrationTests) — unchanged, re-run.
+- **AdminUI live-`/run`:** on docker-dev (`http://localhost:9200`, login disabled — rebuild **both** central-1 and central-2, the rig round-robins), open a driver's edit page, type `0` into a Timeout cell → the warning renders; deploy → grep the central logs for the clamp diagnostic (`Driver resilience config for instance=…: … clamped`) and confirm the driver's subscribe/read still works.
+
+**Effort:** S. **Risk:** Low — clamping only ever *widens* the set of configs that work; in-range configs are byte-identical. Blast radius: parser + builder + one AdminUI component.
+
+---
+
+## 2. S-8 = 03/S12 — MEDIUM — Dispatch hardcodes `isIdempotent: true`; the non-idempotent write safeguard is unreachable
+
+**Restatement:** The only production caller of `ExecuteWriteAsync` is `DriverInstanceActor.HandleWriteAsync` passing `isIdempotent: true` unconditionally (`DriverInstanceActor.cs:597-601`). `WriteIdempotentAttribute` — whose entire purpose is per-tag opt-in to write retries — has zero readers, and its own xmldoc **lies** ("The `CapabilityInvoker` … reads this attribute via reflection once at driver-init time", `WriteIdempotentAttribute.cs:12-14`). Behavior-neutral today (every tier's Write default is `RetryCount: 0`), but #13 made per-instance Write retries operator-authorable — the moment one is configured, **all** of that instance's writes retry, including command-shaped ones (pulse coils, counter increments, Galaxy supervisory writes) where a duplicate is not harmless.
+
+**Verification:** Confirmed (see summary). Additional facts that shape the design: (a) the Phase 6.1 spec itself says retries are "skipped on Write and AlarmAcknowledge **regardless of tier**" (`DriverResilienceOptions.cs:72-73` — currently a default, not an invariant: a parsed override defeats it); (b) the alarm-ack dispatch site (`DriverInstanceActor.cs:~650`, "Ack is a write-shaped operation — the AlarmAcknowledge tier policy never retries") routes through plain `ExecuteAsync(DriverCapability.AlarmAcknowledge, …)`, so a configured `AlarmAcknowledge.retryCount > 0` retries acks by the same mechanism — the finding as filed covers Write only, but a coherent fix must treat both write-shaped capabilities; (c) the `!isIdempotent` arm skips the tracker's `RecordCallStart/Complete` (`CapabilityInvoker.cs:117-135` vs `:70-81`) and allocates a fresh options record + Dictionary per call (= 01/P-4).
+
+**Root cause:** The per-tag idempotence signal was designed (attribute + invoker arm) but never threaded: the dispatch site has only a `TagId` string, no tag-definition metadata, so `true` was hardcoded with a comment rationalizing plain value-writes — silently converting a per-tag safety contract into an instance-wide one.
+
+**Proposed design — enforce the spec's write-shaped no-retry invariant at the parser, AND default the dispatch to non-idempotent (two independent layers), deferring per-tag opt-in.**
+
+Alternatives weighed (attribute-read vs config-flag vs default-flip):
+- *(a) Read `WriteIdempotentAttribute` per tag at the seam* — the designed mechanism, but threading tag-definition metadata from the driver's typed tag rows through `WriteAttribute`/the host routing table to the dispatch site is a feature-sized change across composer/artifact/actor contracts, **and no tag-definition POCO in the tree carries the attribute today**, so it would still resolve to non-idempotent everywhere. Deferred as the documented forward path, not built now.
+- *(b) Delete the `isIdempotent` parameter and document all-writes-idempotent* — codifies the unsafe posture the finding exists to prevent; rejected.
+- *(c — chosen, two layers)*
+  **Layer 1 (parser, spec enforcement):** `ParseOrDefaults` clamps `retryCount` overrides for `Write` and `AlarmAcknowledge` to **0** with a diagnostic ("Write/AlarmAcknowledge never retry (writes are treated as non-idempotent until per-tag opt-in ships); retryCount override ignored"). This turns the spec sentence into an enforced invariant on the *only* JSON→options funnel, and it honestly surfaces the knob's inertness instead of silently ignoring it (the house dead-knob lesson). When the per-tag opt-in ships later, this clamp is the one line to relax.
+  **Layer 2 (dispatch, belt):** flip the dispatch site to `isIdempotent: false` (03/S12's own recommendation) so the invoker's no-retry arm is authoritative even if the parser layer is bypassed. Fix the arm while making it the live path: **cache the no-retry options snapshot once per invoker** (lazy field computed from the ctor snapshot — the options are immutable for the invoker's lifetime per #13's respawn-on-change design), and add the missing `RecordCallStart/Complete` tracker accounting — resolving 01/P-4 (per-write allocation) in the same motion instead of shipping a newly-hot allocating path.
+- **Docs + form:** correct `WriteIdempotentAttribute`'s false claim ("reserved for the per-tag opt-in; **not yet consulted** — dispatch currently treats every write as non-idempotent"); correct the `HandleWriteAsync` comment; annotate/disable the Write + AlarmAcknowledge "Retries" cells in `DriverResilienceSection.razor` (disabled input with a title/tooltip "writes never retry — per-tag opt-in not yet available") so the operator can't author a value the parser will discard. Keep the attribute type — it is the documented forward contract referenced by `DriverCapability`'s xmldoc; deleting it would orphan the design history for a 20-line file.
+
+**Implementation steps:**
+1. Parser: in the merge loop's clamp helper (from S-6), special-case `DriverCapability.Write` and `DriverCapability.AlarmAcknowledge`: any `retryCount` override > 0 → 0 + diagnostic. (Tier defaults are already 0; only overrides can differ.)
+2. `DriverInstanceActor.HandleWriteAsync` (`:597-601`): `isIdempotent: true` → `isIdempotent: false`; rewrite the comment block (`:592-596`) to state the non-idempotent default + the attribute-based future opt-in.
+3. `CapabilityInvoker`: add `private DriverResilienceOptions? _noRetryWriteOptions;` built lazily from `_optionsAccessor()` (one allocation per invoker lifetime); wrap the `!isIdempotent` arm in the same `RecordCallStart/Complete` try/finally the other overloads use (host key: pass the **caller's** `hostName` to the tracker — the `::non-idempotent` suffix is a pipeline-cache key, not an operator-facing host).
+4. Docs: `WriteIdempotentAttribute.cs` remarks; `IDriverCapabilityInvoker.ExecuteWriteAsync` xmldoc (unchanged semantics, but note the current production default is `false`).
+5. Razor: disable the two Retries inputs + tooltip.
+
+**Tests:**
+- `DriverResilienceOptionsParserTests`: `WriteRetryOverride_IsForcedToZero_WithDiagnostic`, `AlarmAcknowledgeRetryOverride_IsForcedToZero_WithDiagnostic`; regression: Read/Subscribe retry overrides still honored.
+- **Production-wiring assertion:** `DriverInstanceActorResilienceWiringTests` (Runtime.Tests) — the existing recording-invoker write test records `ExecuteWriteAsync` args; **first** change its expectation to `isIdempotent == false` (fails on current code), then flip the dispatch (goes green). This is the exact "a transposed flag would compile and ship" class 03/U6 warns about.
+- `CapabilityInvokerTests`: `NonIdempotentWrite_DoesNotRetry_EvenWhenWritePolicyHasRetries` (exists in spirit — extend to assert the cached snapshot: two calls, assert no per-call options divergence via the builder's `CachedPipelineCount` staying flat); `NonIdempotentWrite_RecordsTrackerStartAndComplete` (new — pins the accounting fix).
+- `FlakeyDriverIntegrationTests` (Core.Tests): re-run — the end-to-end retry/breaker behavior tests must stay green (they exercise Read paths).
+
+**Effort:** S. **Risk:** Low-Medium — behavior-changing on the write dispatch path, but the *observable* change is only "an operator-configured Write retry no longer fires", which is the fix. The pipeline key for writes changes (`{host}::non-idempotent`), preserving per-host breaker isolation; the write breaker now samples independently of the read breaker for the same host — acceptable (write failures opening the read path's breaker was never a stated contract) but called out for review.
+
+---
+
+## 3. U-6 — MEDIUM — Bulkhead is documented, defaulted, parsed, authored — and never built: DELETE the knob
+
+**Restatement:** `DriverResilienceOptions.BulkheadMaxConcurrent/BulkheadMaxQueue` (`:23`, `:29`) are defaulted (32/64), merged by the parser (`DriverResilienceOptionsParser.cs:116-117`), authored by the AdminUI form (`ResilienceFormModel.cs:18-20` + two razor inputs), and advertised in the seam docs (`IDriverCapabilityInvoker.cs:5,18`), the analyzer's diagnostic message (`UnwrappedCapabilityCallAnalyzer.cs:105`), and the builder's own composition remark (`DriverResiliencePipelineBuilder.cs:18`) — but `Build` (`:99-176`) adds only timeout → retry → breaker. No concurrency/rate-limiting strategy exists anywhere in `src/`. An operator can set it, the deploy respawns the driver, and nothing changes.
+
+**Verification:** Confirmed + strengthened (see summary sharpening 2): beyond "not wired", the wire option is **provably unable to engage** — actor-serialized dispatch bounds in-flight calls at 1 per driver child, and the lone concurrent fan-out consumer (`AlarmSurfaceInvoker`) has zero production references. Wiring also requires a new package (`Polly.RateLimiting` — absent from `Directory.Packages.props`).
+
+**Root cause:** Phase 6.1 Stream A specified the four-strategy composition; the builder shipped three and the surrounding surface (options, parser, form, docs) was built to the spec rather than to the builder.
+
+**Proposed design — DELETE (recommended), not wire.**
+
+- *Wire (rejected):* `AddConcurrencyLimiter(permitLimit, queueLimit)` after the breaker is ~15 lines + one package — but per the verification it cannot engage under the actor-serialized dispatch model (≤1 in-flight per child), so it would ship as a *second-generation* authored-but-inert knob: configured, built, and still doing nothing. If a genuinely concurrent dispatch surface ever lands (Tier C out-of-process hosts, a wired `AlarmSurfaceInvoker` fan-out, batched `WriteValues`), the strategy can be added then, against a consumer that exercises it.
+- *Delete (chosen):* strip the two fields end-to-end. **Migration-free:** the parser deserializes into a private shape class, so `bulkheadMaxConcurrent`/`bulkheadMaxQueue` keys in *stored* `ResilienceConfig` JSON simply become ignored unknown keys (System.Text.Json default) — no config rewrite, no deploy failure. After C-7's fix the form likewise *preserves* those keys as unknowns instead of stripping them.
+- **Delete scope (code):** `DriverResilienceOptions` two properties + doc example; parser shape properties + the two merge lines + the doc-example JSON (`:14-23`); `ResilienceFormModel` fields + `Shape` properties + `hasAny`/emit lines; `DriverResilienceSection.razor` two inputs (`:10-13`); tests `DriverResilienceOptionsParserTests.BulkheadOverrides_AreHonored` (replace with `BulkheadKeys_InStoredJson_AreIgnored` — pins the migration-free contract), `ResilienceFormModelTests` bulkhead usages.
+- **Doc scope (reword "bulkhead" on the live seam only):** `IDriverCapabilityInvoker.cs:5,18` and `IPerCallHostResolver.cs:31` (→ "in-flight accounting"), `UnwrappedCapabilityCallAnalyzer.cs:15,105` (analyzer *message change* — its own tests assert the text, update them), `DriverResiliencePipelineBuilder.cs:18,36` composition remark, `CapabilityInvoker.cs:42` + `AlarmSurfaceInvoker.cs:55`, `DriverInstanceActor.cs:142` + `DriverHostActor.cs:1630` comments, `Configuration/Entities/DriverInstance.cs:38-39` doc example, and the operator docs that enumerate the pipeline (`docs/OpcUaServer.md:22`, `docs/ReadWriteOperations.md:3`, `docs/security.md:323`, `docs/v1/HistoricalDataAccess.md:33`, `docs/v2/driver-stability.md` if it lists the knob). **Leave untouched:** the `DriverInstanceResilienceStatus.CurrentBulkheadDepth` DB column + all EF migrations (renaming a column is migration churn for a reader-less entity — 03/U10's status-reader work owns that surface; add one doc line on the entity: "in-flight call depth; the Polly bulkhead strategy was removed, R2-02"), `WedgeDetector`'s `BulkheadDepth` parameter (fed by in-flight counts; same deferral), and historical plan documents (`docs/plans/`, `docs/v2/implementation/`, `docs/v2/plan.md` — they are records, not contracts).
+- **Guard for the genre (from OVERALL theme #1):** the reviews note the existing guards catch interface-forwarding and unwrapped-dispatch inertness but not *option-parsed-but-unapplied* inertness. Add the cheap version: `DriverResilienceOptionsTests.Options_properties_are_exactly_the_pipeline_wired_set` — reflection-asserts `DriverResilienceOptions`'s public property set is exactly `{Tier, CapabilityPolicies, RecycleIntervalSeconds}` with a failure message requiring any new knob to (a) be added to the expected list and (b) cite its behavior test. (`RecycleIntervalSeconds` is itself Tier-C-dormant — 01/U-2's documented open question, explicitly **out of this pass's scope**; the guard's expected-list comment records that.)
+
+**Implementation steps:** as scoped above; strip code first (compile errors enumerate every consumer — the safest sweep), then tests, then the doc pass.
+
+**Tests:** `BulkheadKeys_InStoredJson_AreIgnored` (parser); updated `ResilienceFormModelTests` (a stored config carrying bulkhead keys round-trips them as unknown keys — lands with C-7); the property-set guard; analyzer message-text tests updated (`tests/Tooling/…Analyzers.Tests` — the whole-solution CI leg runs them). **Production-wiring assertion:** none needed beyond compile — deletion is self-proving; the guard test is the standing protection.
+
+**Effort:** S-M (mechanical, wide but shallow). **Risk:** Low — no runtime behavior exists to change; the only hazards are missed doc mentions (grep `bulkhead` at the end, excluding `bin/obj/Migrations/docs/plans`) and the analyzer message tests.
+
+---
+
+## 4. C-7 — MEDIUM — `ResilienceFormModel` round-trip is lossy: malformed stored JSON silently overwritten, unknown keys stripped, raw pane shows re-serialized model
+
+**Restatement:** `FromJson` maps only known top-level fields and known capability keys (`ResilienceFormModel.cs:52-74`; the `TryGetValue` at `:67` drops unknown capabilities), and catches `JsonException` by returning an **empty form** (`:59` — with a comment promising a raw-text view "next task" that never shipped). `ToJson` (`:78-101`) emits only the model's own fields. The raw pane (`DriverResilienceSection.razor:37-40`) renders `_m.ToJson()` — the re-serialized model, not the stored text. So a malformed or unknown-key-bearing stored `DriverInstance.ResilienceConfig` renders as blank/partial, and the first keystroke in any resilience field (`EmitAsync`, `:60-66`) silently overwrites the stored override — data loss with no indication the original existed. This violates the preserve-unknown-keys convention the sibling tag editors established (`Uns/TagEditors/ModbusTagConfigModel.cs` — `JsonObject _bag`).
+
+**Verification:** Confirmed at all anchors. `ResilienceFormModelTests.cs` exists (4 tests) with no unknown-key coverage. The `TagConfigJson` helper class (`Uns/TagEditors/TagConfigJson.cs`) provides the house `ParseOrNew`/`GetInt`/`Set`/`Serialize` overlay idiom this fix reuses.
+
+**Root cause:** The form was built from the 2026-05-29 AdminUI-followups plan's typed-shape sketch (which predates the preserve-unknown discipline) and its malformed-JSON story depended on a follow-up raw-view task that was never implemented — the `:59` comment is the fossil.
+
+**Proposed design — adopt the tag-editor `JsonObject`-bag round-trip + a parse-failure fail-safe that never overwrites what it couldn't read.**
+
+- **Round-trip:** `FromJson` parses to a `JsonObject` bag (`JsonNode.Parse`); known top-level fields are read out; `capabilityPolicies` is walked — known capability names populate rows (and *known per-policy fields*; unknown per-policy fields stay in the bag's policy object), unknown capability entries stay untouched in the bag. `ToJson` overlays the model's non-null known values onto the bag (removing a known key when its model value is null — "blank = tier default" is preserved for known fields) and serializes the bag; returns null only when the bag is empty *and* every model field is null (current blank-form contract preserved: `Blank_form_serializes_to_null` stays green).
+- **Malformed stored JSON:** `FromJson` sets `ParseFailed = true` and stores the original text in `RawStoredJson`. Two sub-options: *(i) warn-then-replace* (report's minimum: banner "stored JSON could not be parsed — editing will replace it", edits allowed) vs *(ii) warn-and-lock* (banner + inputs disabled + `EmitAsync` no-ops while `ParseFailed`). **Choose (ii)** — it is the only option with zero data-loss paths; the operator fixes the column via the same path that corrupted it (hand-edit/SQL), or clears it deliberately. Add an explicit "Discard stored JSON and start blank" button inside the warning for the deliberate-replace case (one click sets `ParseFailed = false`, clears the bag, emits null → intentional, visible destruction instead of a silent side effect of an unrelated keystroke).
+- **Raw pane:** render the **bound stored parameter** (`ResilienceConfig`), not `_m.ToJson()` — it then shows the truth in both the healthy and the malformed case. Delete the `:59` fossil comment.
+
+**Implementation steps:**
+1. **Red tests first** in `ResilienceFormModelTests.cs`: `Unknown_top_level_key_survives_round_trip`, `Unknown_capability_entry_survives_round_trip`, `Unknown_per_policy_field_survives_round_trip`, `Malformed_json_sets_ParseFailed_and_ToJson_returns_original_text` — all fail at `f6eaa267`.
+2. Rework `ResilienceFormModel` per the design (drop the private `Shape`/`PolicyShape` classes in favor of the bag + `TagConfigJson`-style helpers — reuse `TagConfigJson` directly if its namespace/accessibility allows (it's `AdminUI.Uns.TagEditors`; referencing it from `Components.Shared.Drivers` is same-assembly — fine), else lift the three needed helpers).
+3. `DriverResilienceSection.razor`: warning banner + disabled inputs + discard button under `ParseFailed`; raw pane → `@(ResilienceConfig ?? "(null — all tier defaults)")`; `EmitAsync` guard.
+4. Keep `Emitted_json_is_consumable_by_the_runtime_parser` green — the bag must still serialize camelCase keys the parser reads (the parser is `PropertyNameCaseInsensitive`, and unknown keys are ignored — verified).
+
+**Tests:** step 1's four tests + updated existing four; interop regression (parser consumes bag-emitted JSON, unknown keys don't produce diagnostics). **Production-wiring assertion / live-`/run`:** on docker-dev (`:9200`, login disabled, **rebuild both centrals**), hand-mangle a driver's `ResilienceConfig` in SQL (`UPDATE … SET ResilienceConfig = '{ not json'` — remember `SET QUOTED_IDENTIFIER ON`), open the driver page → warning renders, inputs disabled, raw pane shows the mangled text, save-without-touching leaves the column byte-identical; then a healthy config with an unknown key (`{"futureKnob":1,"capabilityPolicies":{"Read":{"timeoutSeconds":5}}}`) → edit a field, save, re-read the column: `futureKnob` survived. (This is the AdminUI-has-no-bUnit posture: binding behavior is proven live, model behavior in units.)
+
+**Effort:** S-M. **Risk:** Low — the model is fully unit-tested and the emit contract to the parser is pinned by the interop test; the razor change is small but live-verified (the house lesson: Razor binding bugs pass all unit tests).
+
+---
+
+## 5. S-7 = 03/S13 — LOW — Respawn `Invalidate` race can permanently re-cache a stale-options pipeline
+
+**Restatement:** `DriverCapabilityInvokerFactory.Create` calls `_builder.Invalidate(driverInstanceId)` (`DriverCapabilityInvokerFactory.cs:71`) before the *old* child has terminated (`StopChild` is an async `Context.Stop`; `DriverHostActor.cs:1278-1280` runs ToStop→ToSpawn in one handler). An old-child handler already mid-await can reach `GetOrCreate` **after** the invalidate, re-caching a pipeline built from the old options snapshot under the same `(instance, host, capability)` key — and the cache ignores options on a hit (`DriverResiliencePipelineBuilder.cs:76-79`), so the new invoker serves the stale pipeline **indefinitely**. Millisecond window, permanent + invisible consequence: the operator's ResilienceConfig change silently doesn't apply for the affected keys.
+
+**Verification:** Confirmed at all anchors. Note the actor-name generation counter (`DriverHostActor.cs:1609-1615`) shows the respawn machinery already *tolerates* the old child lingering — the design intent is "old child may still be draining", which is exactly the window.
+
+**Root cause:** The cache key identifies the *instance*, not the *options epoch*; `Invalidate` is a point-in-time sweep racing a writer that can still hold the old epoch.
+
+**Proposed design — make staleness impossible: an options-generation in the pipeline key.**
+
+- **Chosen:** `Create` stamps each invoker with a monotonically increasing `long` generation (`Interlocked.Increment` on a factory field); `CapabilityInvoker` passes it to `GetOrCreate`; `PipelineKey` gains `long Generation`. An old invoker's post-invalidate re-cache lands under its **old** generation — a key the new invoker (new generation) never reads. `Invalidate(instanceId)` still sweeps by instance (all generations), so the next respawn's `Create` clears any lingering old-generation entries: stale entries are bounded (≤ one epoch's hosts×capabilities) and never served. Per-instance invalidate semantics and respawn-on-change are preserved exactly; the seam interfaces are untouched (the generation is a Core-internal ctor param on the concrete `CapabilityInvoker` + a defaulted `GetOrCreate` parameter, so every existing caller/test compiles unchanged).
+- *Alternative (a) — options-aware cache* (store the options reference beside the pipeline; rebuild on reference mismatch): self-healing and no key growth, but adds a per-call reference comparison on the hot path and can thrash-rebuild while the old child drains. Rejected as strictly worse than epoch keying.
+- *Alternative (b) — defer the new spawn until the old child's `Terminated`*: closes the race at the actor protocol (watch + stash in `DriverHostActor`) — the largest blast radius of the three for a milliseconds-wide window, and it would change respawn latency semantics. Rejected; noted as the fallback if a second options-epoch consumer ever appears.
+
+**Implementation steps:**
+1. `DriverResiliencePipelineBuilder`: `PipelineKey` gains `long Generation`; `GetOrCreate(…, long optionsGeneration = 0)`; `Invalidate` unchanged (matches on `DriverInstanceId` only).
+2. `CapabilityInvoker`: new optional ctor param `long optionsGeneration = 0`, threaded into both `ResolvePipeline` and the non-idempotent arm's `GetOrCreate`.
+3. `DriverCapabilityInvokerFactory`: `private long _generation;` → `var gen = Interlocked.Increment(ref _generation);` per `Create`, passed to the invoker; keep the `Invalidate` call (it is now the cleanup, not the correctness mechanism — update its comment to say so).
+
+**Tests:**
+- `DriverResiliencePipelineBuilderTests.StaleGeneration_ReCache_IsNotServed_ToNewGeneration` — deterministic replay of the race: `GetOrCreate(key, gen1, optsA)`; `Invalidate(instance)`; `GetOrCreate(key, gen1, optsA)` (the old child's late call re-caches); `GetOrCreate(key, gen2, optsB)` must return a pipeline honoring `optsB` (behavioral assert: optsB's retry fires / optsA's timeout doesn't). No timing dependence — generations make the interleaving expressible sequentially.
+- **Production-wiring assertion:** `DriverCapabilityInvokerFactoryTests.Respawn_interleaved_with_old_invoker_call_still_applies_new_options` — `Create(id, …, oldJson)` → execute once (cache warm) → `Create(id, …, newJson)` interleaved with an old-invoker call *between* the new `Create`'s invalidate and the new invoker's first call (drive the old invoker directly — it's a captured reference, exactly what the old child holds) → assert the new invoker's behavior matches `newJson`. Fails on current code; green with epochs.
+
+**Effort:** S. **Risk:** Low — additive keying; default parameter keeps every existing call site identical; the only semantic change is that a stale epoch's re-cache becomes unreachable garbage instead of a live poisoning.
+
+---
+
+## Task breakdown
+
+Bite-sized TDD tasks. Each carries classification, estimate, parallelism, files, steps with exact test commands, and the commit message to use at green. Run commands from the repo root. House verification bar: **every behavior change lands with (a) a deterministic unit guard and (b) a production-wiring assertion — unit-green ≠ wired**; AdminUI changes additionally get a live-`/run` pass on docker-dev (`http://localhost:9200`, login disabled — the rig round-robins central-1/central-2, so **rebuild BOTH** before verifying).
+
+### Task 0 — S-6 brick-repro test (RED — must fail on current code)
+**Classification:** test-first (regression repro) · **Estimated implement time:** 5 min · **Parallelizable with:** 1 · **Files:** `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/ResilienceConfigBrickTests.cs` (new)
+1. Write `ParsedConfig_WithZeroTimeout_DoesNotBrickCapabilityCalls`: `ParseOrDefaults(DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0}}}", out _)` → `new DriverResiliencePipelineBuilder().GetOrCreate("i1","h1",DriverCapability.Read, opts)` → `await pipeline.ExecuteAsync(ct => ValueTask.FromResult(42), CancellationToken.None)` succeeds. Second fact `ParsedConfig_WithBreakerThresholdOne_DoesNotBrickCapabilityCalls` with `{"breakerFailureThreshold":1}`.
+2. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~ResilienceConfigBrickTests"` → **expect 2 FAILURES** (Polly `ValidationException`) — this proves the brick on `f6eaa267`. Do not commit red; the commit lands with Task 2/3 green.
+
+### Task 1 — `ResiliencePolicyRanges` in Core.Abstractions
+**Classification:** implementation (new type) · **Estimated implement time:** 4 min · **Parallelizable with:** 0 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ResiliencePolicyRanges.cs` (new)
+1. Add the constants class exactly as specified in §1 (values: 1 / 86 400 / 0 / 100 / 0 / 2 / 10 000), xmldoc citing Polly.Core 8.6.6's validation as the derivation.
+2. `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` → clean. Commit with Task 2.
+
+### Task 2 — Parser range clamps + diagnostics (turns Task 0 half-green)
+**Classification:** bugfix (TDD) · **Estimated implement time:** 5 min · **Parallelizable with:** 3 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs`, `tests/Core/…Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs`
+1. RED: add the seven clamp tests from §1 (`TimeoutSeconds_Zero_FallsBackToTierDefault_WithDiagnostic`, `TimeoutSeconds_AbovePollyMax_Clamped`, `RetryCount_Negative_ClampedToZero`, `RetryCount_AboveCap_ClampedTo100`, `BreakerThreshold_One_ClampedToTwo`, `BreakerThreshold_Negative_DisablesBreaker`, `MultipleClamps_AllSurfaceInDiagnostic`) + regression `InRangeOverrides_PassThrough_NoDiagnostic`. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResilienceOptionsParserTests"` → new tests fail.
+2. GREEN: implement `ClampPolicy` in the merge loop using `ResiliencePolicyRanges` (semantics per §1: timeout ≤0 → per-capability tier default; others clamp) + switch the diagnostic to an appending helper (`AppendDiagnostic(ref string?, string)` joining with `"; "`). Re-run the filter → all green (verify the pre-existing 15 parser tests still pass — the `??=` → append change must not break `UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail`).
+3. Commit: `fix(resilience): range-clamp ResilienceConfig overrides in the parser (01/S-6) — timeout/retry/breaker clamped with diagnostics, never brick`
+
+### Task 3 — Builder belt-and-braces clamp (turns Task 0 fully green)
+**Classification:** bugfix (TDD) · **Estimated implement time:** 5 min · **Parallelizable with:** 2 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs`, `tests/Core/…Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs`
+1. RED: add `Build_NeverThrows_ForHostileDirectOptions` — matrix `{int.MinValue, -1, 0, 1, int.MaxValue}` across the three `CapabilityPolicy` fields, constructed directly (bypassing the parser), each `GetOrCreate` + trivial execute must succeed. Fails on current code.
+2. GREEN: clamp in `Build` (`Math.Clamp` timeout to `[MinTimeoutSeconds, MaxTimeoutSeconds]`; `Math.Min(retry, MaxRetryCount)` under the `> 0` guard; `Math.Max(MinEnabledBreakerFailureThreshold, threshold)` for `MinimumThroughput` under the `> 0` guard). Update the class remark to state the never-throws invariant.
+3. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResiliencePipelineBuilderTests|FullyQualifiedName~ResilienceConfigBrickTests"` → all green **including Task 0's repro**.
+4. Commit: `fix(resilience): pipeline Build clamps policy values — building a pipeline can never throw Polly ValidationException (01/S-6 belt-and-braces)`
+
+### Task 4 — Factory-seam wiring assertion for S-6
+**Classification:** production-wiring guard · **Estimated implement time:** 4 min · **Parallelizable with:** 5, 6 · **Files:** `tests/Core/…Core.Tests/Resilience/DriverCapabilityInvokerFactoryTests.cs`
+1. Add `Create_with_out_of_range_config_yields_working_invoker` — `factory.Create("i1","Modbus", "{\"capabilityPolicies\":{\"Subscribe\":{\"timeoutSeconds\":0,\"breakerFailureThreshold\":1}}}")` → `ExecuteAsync(Subscribe, …)` succeeds AND the recording logger (the suite's existing `Log` fake) captured a clamp diagnostic warning. This is the exact `DriverHostActor.SpawnChild` path (`DriverHostActor.cs:1633`).
+2. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverCapabilityInvokerFactoryTests"` → green (should pass immediately post-Tasks 2/3 — it is the wiring proof, not new behavior). Also re-run the DI-binding guard unchanged: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~ResilienceInvokerFactoryRegistrationTests"`.
+3. Commit: `test(resilience): factory-seam guard — hostile ResilienceConfig cannot brick the spawn path (01/S-6 wiring proof)`
+
+### Task 5 — AdminUI form range validation + section warnings
+**Classification:** feature (authoring feedback) · **Estimated implement time:** 5 min · **Parallelizable with:** 4, 6 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs`, `…/DriverResilienceSection.razor`, `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs`
+1. RED: `Validate_flags_out_of_range_values_with_capability_and_range` (timeout 0, retry −1, breaker 1 → 3 messages naming capability/field/legal range) + `Validate_passes_in_range_and_blank`. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModelTests"` → fail.
+2. GREEN: `Validate()` using `ResiliencePolicyRanges` (Core.Abstractions — reachable transitively via the Runtime project reference; do NOT add a Core reference); render `_m.Validate()` results as a warning list under the table in the razor (emit still proceeds — parser clamp is authoritative).
+3. Commit: `feat(adminui): range-validation warnings on the resilience override form (01/S-6 authoring layer)`
+
+### Task 6 — S-8 parser layer: write-shaped capabilities never retry
+**Classification:** bugfix (TDD) · **Estimated implement time:** 4 min · **Parallelizable with:** 4, 5 (same file as Task 2 — rebase, don't parallel-edit) · **Files:** `src/Core/…/Resilience/DriverResilienceOptionsParser.cs`, `tests/Core/…/Resilience/DriverResilienceOptionsParserTests.cs`
+1. RED: `WriteRetryOverride_IsForcedToZero_WithDiagnostic` + `AlarmAcknowledgeRetryOverride_IsForcedToZero_WithDiagnostic` + regression `ReadRetryOverride_StillHonored`. Filter run → fail.
+2. GREEN: in `ClampPolicy`, for `DriverCapability.Write`/`AlarmAcknowledge` force `RetryCount` overrides > 0 to 0 with the diagnostic text from §2. Update the `GetTierDefaults` xmldoc (`DriverResilienceOptions.cs:72-73`) from "defaults skip retries" to "the parser enforces no-retry on these capabilities (R2-02/S-8); per-tag opt-in via WriteIdempotentAttribute is the future relaxation".
+3. Commit: `fix(resilience): enforce the spec's no-retry invariant for Write/AlarmAcknowledge overrides (01/S-8, 03/S12 layer 1)`
+
+### Task 7 — S-8 dispatch layer: `isIdempotent: false`
+**Classification:** bugfix (TDD, behavior-changing) · **Estimated implement time:** 5 min · **Parallelizable with:** — (Runtime wiring test is the gate) · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs`
+1. RED: in the wiring test's recorded-write assertion, change the expected flag to `isIdempotent == false`. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DriverInstanceActorResilienceWiringTests"` → fails (records `true`).
+2. GREEN: flip `:599` to `isIdempotent: false`; rewrite the `:592-596` comment (non-idempotent default; command-shaped writes must never replay; `WriteIdempotentAttribute` is the unshipped per-tag opt-in). Re-run filter → green. Run the full Runtime suite: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` (write-path behavior tests must hold — the null invoker passes through identically).
+3. Commit: `fix(runtime): write dispatch defaults to non-idempotent — no-retry arm authoritative (03/S12 layer 2)`
+
+### Task 8 — S-8/P-4 invoker: cached no-retry snapshot + tracker accounting
+**Classification:** bugfix + perf (TDD) · **Estimated implement time:** 5 min · **Parallelizable with:** 9 · **Files:** `src/Core/…/Resilience/CapabilityInvoker.cs`, `tests/Core/…/Resilience/CapabilityInvokerTests.cs`
+1. RED: `NonIdempotentWrite_RecordsTrackerStartAndComplete` (real `DriverResilienceStatusTracker`, assert in-flight returns to 0 and the call was counted) + `NonIdempotentWrite_ReusesCachedNoRetryOptions` (two calls; assert `builder.CachedPipelineCount` grows by exactly 1 and, via a counting `_optionsAccessor`, that the accessor was invoked at most once). Filter: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~CapabilityInvokerTests"` → fail.
+2. GREEN: lazy `_noRetryWriteOptions` field (compute once from `_optionsAccessor()`; safe — options are immutable per invoker lifetime by #13's respawn-on-change design; benign race tolerated with a simple null-check-assign); wrap the arm in the same `RecordCallStart/Complete` try/finally as `ExecuteAsync`, keyed on the caller's `hostName` (not the `::non-idempotent` cache suffix). Update the arm's comment (it currently justifies once-per-call snapshotting — now it justifies once-per-lifetime).
+3. Commit: `fix(resilience): non-idempotent write arm — cache the no-retry snapshot (01/P-4) and record tracker in-flight accounting (S-8 residual)`
+
+### Task 9 — S-8 docs + form annotation
+**Classification:** docs + small UI · **Estimated implement time:** 4 min · **Parallelizable with:** 8 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/WriteIdempotentAttribute.cs`, `…/IDriverCapabilityInvoker.cs`, `src/Server/…/AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
+1. Fix `WriteIdempotentAttribute`'s false "reads this attribute via reflection" remark → "reserved for per-tag opt-in; **not yet consulted** — dispatch treats every write as non-idempotent (R2-02/S-8)". Note the production default on `ExecuteWriteAsync`'s xmldoc.
+2. Razor: disable the Write and AlarmAcknowledge Retries inputs (`disabled` + `title="Writes/acks never retry — per-tag opt-in not yet available"`).
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModelTests"` (unchanged model → green). Commit: `docs(resilience): correct WriteIdempotentAttribute's unwired claim; disable Write/Ack retry authoring cells (S-8)`
+
+### Task 10 — U-6 delete: options + parser + Core tests
+**Classification:** deletion (dead knob) · **Estimated implement time:** 5 min · **Parallelizable with:** — (parser file; after 2/6) · **Files:** `src/Core/…/Resilience/DriverResilienceOptions.cs`, `…/DriverResilienceOptionsParser.cs`, `tests/Core/…/Resilience/DriverResilienceOptionsParserTests.cs`, `…/DriverResilienceOptionsTests.cs`
+1. Remove `BulkheadMaxConcurrent`/`BulkheadMaxQueue` from the options record, the parser shape + merge lines + the doc-example JSON. Compile errors enumerate stragglers.
+2. Replace `BulkheadOverrides_AreHonored` with `BulkheadKeys_InStoredJson_AreIgnored` (JSON carrying both keys parses clean, no diagnostic — pins the migration-free contract).
+3. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~Resilience"` → green. Commit: `refactor(resilience): delete the dead bulkhead knob — options + parser (01/U-6); stored JSON keys become ignored unknowns`
+
+### Task 11 — U-6 delete: form + razor + AdminUI tests
+**Classification:** deletion · **Estimated implement time:** 4 min · **Parallelizable with:** 12 · **Files:** `src/Server/…/AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs`, `…/DriverResilienceSection.razor`, `tests/Server/…AdminUI.Tests/ResilienceFormModelTests.cs`
+1. Strip the two model fields + shape props + `hasAny` terms + the two razor inputs (`:10-13`; keep `RecycleIntervalSeconds`); update the form tests (drop bulkhead usages; the C-7 tasks will pin that stored bulkhead keys *survive* as unknowns).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModelTests"` → green. Commit: `refactor(adminui): remove bulkhead fields from the resilience form (01/U-6)`
+
+### Task 12 — U-6 doc sweep + knob-inertness guard
+**Classification:** docs + guard test · **Estimated implement time:** 5 min · **Parallelizable with:** 11 · **Files:** seam xmldocs + analyzer + operator docs per §3 scope; `tests/Core/…/Resilience/DriverResilienceOptionsTests.cs`; `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/` (message-text assertions)
+1. Reword "bulkhead" on the live seam (§3 list: `IDriverCapabilityInvoker.cs:5,18`, `IPerCallHostResolver.cs:31`, `UnwrappedCapabilityCallAnalyzer.cs:15,105`, `DriverResiliencePipelineBuilder.cs:18,36`, `CapabilityInvoker.cs:42`, `AlarmSurfaceInvoker.cs:55`, `DriverInstanceActor.cs:142`, `DriverHostActor.cs:1630`, `Configuration/Entities/DriverInstance.cs:38-39`, one annotation line on `DriverInstanceResilienceStatus.CurrentBulkheadDepth`, and `docs/OpcUaServer.md:22` / `docs/ReadWriteOperations.md:3` / `docs/security.md:323` / `docs/v1/HistoricalDataAccess.md:33` / `docs/v2/driver-stability.md`). Leave migrations + `WedgeDetector` + historical plan docs. Final check: `grep -rn -i bulkhead src docs --include='*.cs' --include='*.razor' --include='*.md' | grep -v 'bin/\|obj/\|Migrations/\|docs/plans/\|docs/v2/implementation/\|docs/v2/plan.md\|CurrentBulkheadDepth\|WedgeDetector'` → empty.
+2. Add `Options_properties_are_exactly_the_pipeline_wired_set` (reflection guard, expected set `{Tier, CapabilityPolicies, RecycleIntervalSeconds}`, failure message demanding a behavior test per new knob; comment noting `RecycleIntervalSeconds` is Tier-C-dormant per 01/U-2, out of scope here).
+3. Analyzer message change: `dotnet test tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests` → green after updating any message-text assertions.
+4. Commit: `docs+test(resilience): bulkhead doc sweep + parsed-knob-must-be-wired guard (01/U-6, OVERALL theme 1)`
+
+### Task 13 — C-7 red tests: non-lossy round-trip
+**Classification:** test-first · **Estimated implement time:** 5 min · **Parallelizable with:** — · **Files:** `tests/Server/…AdminUI.Tests/ResilienceFormModelTests.cs`
+1. Add the four red tests from §4 step 1 (`Unknown_top_level_key_survives_round_trip` — use `bulkheadMaxConcurrent` as the unknown key, doubling as the U-6 continuity proof; `Unknown_capability_entry_survives_round_trip`; `Unknown_per_policy_field_survives_round_trip`; `Malformed_json_sets_ParseFailed_and_ToJson_returns_original_text`).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModelTests"` → 4 failures. No commit (lands with Task 14).
+
+### Task 14 — C-7 implement: `JsonObject`-bag round-trip + `ParseFailed`
+**Classification:** bugfix (TDD) · **Estimated implement time:** 5 min · **Parallelizable with:** — · **Files:** `src/Server/…/AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs`
+1. Rework per §4: bag-based `FromJson`/`ToJson` (reuse `AdminUI.Uns.TagEditors.TagConfigJson` helpers — same assembly), `ParseFailed` + `RawStoredJson`, null-when-empty contract preserved, delete the `:59` fossil comment and the private `Shape`/`PolicyShape` classes.
+2. Filter run → all `ResilienceFormModelTests` green including Task 13's four and the parser-interop test.
+3. Commit: `fix(adminui): non-lossy ResilienceFormModel round-trip — preserve unknown keys, never overwrite unparseable stored JSON (04/C-7)`
+
+### Task 15 — C-7 razor: warning, lock-on-ParseFailed, truthful raw pane (+ live-`/run`)
+**Classification:** UI + live verification · **Estimated implement time:** 5 min (+ live pass) · **Parallelizable with:** 16 · **Files:** `src/Server/…/AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
+1. `ParseFailed` ⇒ warning banner + all inputs disabled + `EmitAsync` no-op + "Discard stored JSON and start blank" button (explicit destructive action → clears bag, emits null); raw pane renders the bound `ResilienceConfig` parameter, not `_m.ToJson()`.
+2. **Live-`/run` (AdminUI has no bUnit — binding must be driven):** rebuild BOTH docker-dev centrals; SQL-mangle one driver's `ResilienceConfig` (`SET QUOTED_IDENTIFIER ON;` first) → page shows warning/locked/raw truth and save-untouched leaves the column byte-identical; then the unknown-key survival check from §4. Also drive the S-6 (Task 5) warning and the S-8 (Task 9) disabled cells in the same session.
+3. Commit: `fix(adminui): resilience section — parse-failure lockout + truthful raw pane, live-verified (04/C-7)`
+
+### Task 16 — S-7 builder: generation-keyed pipeline cache
+**Classification:** bugfix (TDD) · **Estimated implement time:** 5 min · **Parallelizable with:** 15 · **Files:** `src/Core/…/Resilience/DriverResiliencePipelineBuilder.cs`, `src/Core/…/Resilience/CapabilityInvoker.cs`, `tests/Core/…/Resilience/DriverResiliencePipelineBuilderTests.cs`
+1. RED: `StaleGeneration_ReCache_IsNotServed_ToNewGeneration` (deterministic interleave per §5 — behavioral assert on the new options). Fails: without generations both calls share one key.
+2. GREEN: `PipelineKey` + `GetOrCreate(…, long optionsGeneration = 0)`; `CapabilityInvoker` optional ctor param threaded to both resolve sites. All existing builder/invoker tests compile unchanged (defaulted param).
+3. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResiliencePipelineBuilderTests|FullyQualifiedName~CapabilityInvokerTests"` → green. Commit: `fix(resilience): options-generation in the pipeline cache key — stale respawn re-cache unreachable (01/S-7, 03/S13)`
+
+### Task 17 — S-7 factory: per-Create generation + interleave wiring proof
+**Classification:** bugfix + production-wiring guard · **Estimated implement time:** 4 min · **Parallelizable with:** — · **Files:** `src/Core/…/Resilience/DriverCapabilityInvokerFactory.cs`, `tests/Core/…/Resilience/DriverCapabilityInvokerFactoryTests.cs`
+1. RED: `Respawn_interleaved_with_old_invoker_call_still_applies_new_options` (per §5 — old invoker reference re-caches between the new `Create` and the new invoker's first call; new invoker must behave per `newJson`). Fails pre-fix.
+2. GREEN: `Interlocked.Increment` generation per `Create`, passed to the invoker; update the `Invalidate` comment (now cleanup, correctness is the epoch key).
+3. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverCapabilityInvokerFactoryTests"` → green. Commit: `fix(resilience): factory stamps a per-Create options generation — closes the respawn stale-pipeline race (01/S-7)`
+
+### Task 18 — Wrap-up: full-solution gate + bookkeeping
+**Classification:** verification + docs · **Estimated implement time:** 5 min (+ suite runtime) · **Parallelizable with:** — · **Files:** `archreview/plans/STATUS.md`, `archreview/plans/00-INDEX.md` (if it tracks R2 items)
+1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 warnings — OTOPCUA0001 must stay silent: no dispatch site was unwrapped) then `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — the whole-solution leg is the CI gate; the fail-on-skip discipline applies.
+2. Record the pass in `STATUS.md` (completed table + re-review findings table: 01/S-6, 01/U-6, 01/S-8=03/S12, 04/C-7, 01/S-7=03/S13 → done, with commits) and note the two report anchor drifts from this plan's Verification summary for the next report refresh.
+3. Commit: `docs(archreview): record R2-02 resilience hardening pass in STATUS.md`
+
+---
+
+## Suggested PR sequencing
+
+1. **PR-1 (S-6, the High):** Tasks 0–5 — parser/builder clamps + factory guard + form warnings. Ships the brick fix alone; smallest reviewable close of the round's #2-ranked finding.
+2. **PR-2 (S-8):** Tasks 6–9 — no-retry invariant + dispatch flip + invoker arm hardening + docs.
+3. **PR-3 (U-6):** Tasks 10–12 — bulkhead deletion + guard.
+4. **PR-4 (C-7):** Tasks 13–15 — non-lossy form + live-verify (one docker-dev session covers PR-1/2/4's UI checks if landed together).
+5. **PR-5 (S-7):** Tasks 16–17 — generation-keyed cache.
+6. Task 18 rides the last PR.
+
+**Overall effort:** Small-Medium (matches 00-OVERALL action #2) — ≈ 19 bite-sized tasks, roughly one focused day including the live-`/run` session. **Overall risk:** Low — every behavior change is either strictly widening (clamps), strictly safening (write no-retry), or deletion of provably-inert surface; the two hot-path touches (write arm, pipeline key) are covered by behavioral guards and the OTOPCUA0001 analyzer keeps every dispatch site wrapped.
diff --git a/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json
new file mode 100644
index 00000000..26510721
--- /dev/null
+++ b/archreview/plans/R2-02-resilience-config-hardening-plan.md.tasks.json
@@ -0,0 +1,25 @@
+{
+  "planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md",
+  "tasks": [
+    { "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "pending", "blockedBy": [] },
+    { "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "pending", "blockedBy": [] },
+    { "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "pending", "blockedBy": [0, 1] },
+    { "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "pending", "blockedBy": [0, 1] },
+    { "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "pending", "blockedBy": [2, 3] },
+    { "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "pending", "blockedBy": [1] },
+    { "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "pending", "blockedBy": [2] },
+    { "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "pending", "blockedBy": [6] },
+    { "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "pending", "blockedBy": [7] },
+    { "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "pending", "blockedBy": [7, 5] },
+    { "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "pending", "blockedBy": [2, 6] },
+    { "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "pending", "blockedBy": [5, 10] },
+    { "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "pending", "blockedBy": [10, 11] },
+    { "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "pending", "blockedBy": [11] },
+    { "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "pending", "blockedBy": [13] },
+    { "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "pending", "blockedBy": [14] },
+    { "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "pending", "blockedBy": [3] },
+    { "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "pending", "blockedBy": [16] },
+    { "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "pending", "blockedBy": [4, 8, 9, 12, 15, 17] }
+  ],
+  "lastUpdated": "2026-07-12"
+}
diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md b/archreview/plans/R2-03-vt-failure-quality-plan.md
new file mode 100644
index 00000000..44421932
--- /dev/null
+++ b/archreview/plans/R2-03-vt-failure-quality-plan.md
@@ -0,0 +1,675 @@
+# Design + Implementation Plan — R2-03: VT script-failure quality + cache-clear hardening
+
+- **Source report:** `archreview/02-scripting-alarms.md` (2026-07-12 re-review)
+- **Review commit:** `f6eaa267` · **Plan verified against tree at:** `f6eaa267` (master, clean)
+- **Scope:** OVERALL prioritized action item **#3** — "Publish Bad quality on VT script failure/timeout
+  (match the dormant engine's BadInternalError); optionally gate the apply-boundary cache clear on
+  changed scripts" — covering findings **02/S13** (Medium — failure/timeout never degrades node quality),
+  **02/S12** (Medium — apply-boundary `ClearCompiledScripts` races in-flight child evaluations), and
+  **02/P7** (Low — unconditional per-apply cache flush forces full VT recompilation on every deploy).
+- **Touches:** `Runtime/VirtualTags/VirtualTagActor.cs`, `Runtime/VirtualTags/VirtualTagHostActor.cs`,
+  `Host/Engines/RoslynVirtualTagEvaluator.cs`, `Runtime.Tests/VirtualTags/*`,
+  `Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`. **No Core.Scripting / Commons-contract /
+  OPC UA-sink changes** except one additive field on `VirtualTagActor.EvaluationResult`.
+
+## Verification summary
+
+Every anchor in the three findings was opened at the cited file:line against the working tree at
+`f6eaa267`. **All three findings CONFIRMED. One minor anchor drift** (noted below); everything else is
+line-exact.
+
+- **S13** — Confirmed. `VirtualTagActor.OnDependencyChanged` returns without any publish on
+  `!result.Success` (`Runtime/VirtualTags/VirtualTagActor.cs:123-128`) and on evaluator-threw
+  (`:115-121`) — Warning `ScriptLogEntry` + `fail` metric only. The host bridge hard-codes
+  `OpcUaQuality.Good` into every `AttributeValueUpdate` (`Runtime/VirtualTags/VirtualTagHostActor.cs:179-180`)
+  and hard-codes `0u /* StatusCodes.Good */` into the historize snapshot (`:187-188`).
+  `EvaluationResult` (`VirtualTagActor.cs:23`) carries no quality at all, so the child *cannot* express
+  degradation today. Confirmed downstream that Bad quality *is* honored end to end once published:
+  `OpcUaPublishActor.HandleAttributeUpdate` → `IOpcUaAddressSpaceSink.WriteValue` →
+  `OtOpcUaNodeManager.WriteValue` (`OpcUaServer/OtOpcUaNodeManager.cs:261-281`) sets
+  `variable.StatusCode = StatusFromQuality(quality)` + `ClearChangeMasks`, where `StatusFromQuality`
+  (`:2418-2423`) maps `Good→StatusCodes.Good`, `Uncertain→StatusCodes.Uncertain`, `_→StatusCodes.Bad`.
+  So the *only* missing plumbing is child → bridge.
+- **S13 anchor drift (minor):** the report cites the dormant engine's failure mapping at
+  `VirtualTagEngine.cs:307,328`. At `f6eaa267`, line **307** is the *cold-start* guard
+  (`BadWaitingForInitialData` when inputs aren't ready yet — `AreInputsReady`); the actual
+  failure→`BadInternalError` (`0x80020000u`) mappings are at **327-329** (uncoercible result), **334**
+  (`ScriptTimeoutException`), and **343** (script threw). The substance of the finding is unaffected —
+  and line 305-311 turns out to be load-bearing for this plan's design (the inputs-ready gate, below).
+- **S12** — Confirmed. `VirtualTagHostActor.OnApply` calls `ClearCompiledScripts()` first thing
+  (`VirtualTagHostActor.cs:92`), on the host's dispatcher thread, with nothing gating concurrent child
+  evaluations. The race window is inside one `RoslynVirtualTagEvaluator.Evaluate` call: the evaluator is
+  fetched at `RoslynVirtualTagEvaluator.cs:69` (`_cache.GetOrCompile`), the host's `Clear()`
+  value-scoped-removes **and disposes** it (`CompiledScriptCache.cs:113-116` →
+  `ScriptEvaluator.Dispose` → `_alc.Unload()`), and the child's subsequent run
+  (`RoslynVirtualTagEvaluator.cs:108-109`, via `TimedScriptEvaluator.RunAsync`'s `Task.Run`) trips the
+  disposed guard (`ScriptEvaluator.cs:180`) → `ObjectDisposedException` surfaces through
+  `GetAwaiter().GetResult()` into the generic `catch (Exception)` (`RoslynVirtualTagEvaluator.cs:116-120`)
+  → `Failure("script threw: Cannot access a disposed object…")` → dropped update (and, per S13, no
+  quality degradation either). The contrast with `ScriptedAlarmEngine` holds: its per-generation
+  `_compileCache.Clear()` (`ScriptedAlarmEngine.cs:209`) runs **under `_evalGate` with upstream
+  subscriptions already torn down**, so no alarm evaluation can hold a disposed evaluator. The secondary
+  wrinkle also confirmed: the clear runs *before* the removed/changed children are stopped
+  (`VirtualTagHostActor.cs:98-118`) and `Context.Stop` is asynchronous, so a doomed child can recompile
+  its stale expression into the fresh cache for one generation (bounded — not a leak).
+  Note a mid-*execution* clear is safe: `AssemblyLoadContext.Unload()` is cooperative (the executing
+  delegate keeps the ALC alive); only the guard at `ScriptEvaluator.cs:180` — i.e. dispose landing
+  between fetch and run-start — produces the dropped update. The `Task.Run` hop inside
+  `TimedScriptEvaluator` widens exactly that window.
+- **P7** — Confirmed. The clear at `VirtualTagHostActor.cs:92` is unconditional per `ApplyVirtualTags`,
+  including a byte-identical redeploy whose plans all diff equal (the no-churn path proven by
+  `ApplyVirtualTags_does_not_respawn_child_when_plan_unchanged`). The host already keeps last
+  generation's plans in `_planByVtag` (`:50`, rebuilt at `:160-164`) — the comparison input for a
+  conditional clear is already sitting in actor state. `EquipmentVirtualTagPlan` has element-wise value
+  equality including `Expression` (`OpcUaServer/AddressSpaceComposer.cs:179-188`). The existing wiring
+  guard `VirtualTagHostActorTests.ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation`
+  asserts the clear fires on an **identical** second apply — that test encodes the P7 behavior and must
+  be rewritten, not just kept green (called out in the task breakdown).
+- **Existing-test impact identified:** `VirtualTagActorTests.Evaluator_failure_publishes_ScriptLogEntry_warning`
+  ends with `parent.ExpectNoMsg(...)` — under the S13 fix a failure now *does* send the parent a
+  Bad-quality `EvaluationResult`, so that assertion inverts (task R2-03-T3 updates it deliberately).
+- **Design constraint verified:** the value fan-out's quality vocabulary is the coarse 3-value
+  `OpcUaQuality { Good, Uncertain, Bad }` (`Commons/OpcUa/IOpcUaAddressSpaceSink.cs:99`) — there is no
+  sub-code channel to the node, and `AttributeValueUpdate` (`Runtime/OpcUa/OpcUaPublishActor.cs:42`)
+  already carries it. `NullVirtualTagEvaluator` returns `NoChange` (Success=true), so the dev/Mac path
+  can never emit Bad. The passthrough fast-path returns `Ok(null)` for a missing dep (never Failure), so
+  it is unaffected by all three fixes.
+
+---
+
+## Priority ordering
+
+Per the source report's Priority Recommendations (#1 = S13, #2 = S12) and OVERALL action item #3:
+
+1. **S13** (Medium, report's top item) — failure/timeout → node quality. The stale-Good repro test lands first and must fail on current code.
+2. **S12** (Medium) — close the clear-vs-in-flight-evaluate race (retry-once in the adapter).
+3. **P7** (Low) — gate the apply-boundary clear on an actually-changed script set.
+
+All three are one PR (`fix/archreview-r2-03-vt-failure-quality`): S13 and S12 touch disjoint files;
+P7 touches the same `OnApply` region S13's bridge work sits next to.
+
+---
+
+## 1. S13 — MEDIUM — Script failure/timeout never degrades the OPC UA node's quality: stale value stays Good forever
+
+**Restatement:** When a VT script evaluation fails (compile error, runtime throw, sandbox violation, or
+the U2-era timeout), `VirtualTagActor` logs + meters and returns without publishing anything, and the
+host bridge only ever publishes `OpcUaQuality.Good`. A previously-working script that starts failing
+leaves its node serving the last Good value with its old timestamp indefinitely — OPC UA clients, the
+continuous-historization recorder, and ScadaBridge cannot distinguish "healthy but unchanged" from
+"script broken for a week". The dormant `VirtualTagEngine` maps this correctly to `BadInternalError`;
+the live actor path never implemented it.
+
+**Verification:** Confirmed (see summary). Bad quality is fully plumbed from `AttributeValueUpdate`
+through the sink to the SDK node — the gap is exactly `VirtualTagActor` (no quality concept) and the
+`VirtualTagHostActor` bridge (hard-coded Good at `:179-180`, hard-coded `0u` historize status at
+`:187-188`).
+
+**Root cause:** `EvaluationResult` was designed as a success-only message ("emit to the parent whenever
+the value actually changes"); failures were treated purely as an observability event (script-log +
+metric). The quality dimension was left to the materialiser's initial `BadWaitingForInitialData` and
+never revisited when the U2 remediation made timeouts a first-class, expected failure mode.
+
+**Proposed design:**
+
+*(a) StatusCode policy — coarse `OpcUaQuality.Bad` for both failure and timeout.* The node will read
+`StatusCodes.Bad` (0x80000000) via the existing `StatusFromQuality` mapping. This "matches the dormant
+engine's BadInternalError" at the level that matters — the severity bit that flips client/consumer
+handling — and the failure *kind* (timeout vs throw vs compile) is already delivered verbatim to
+operators as the script-log `Reason` string on the `script-logs` topic. Alternatives rejected:
+- *Widen `OpcUaQuality` with `BadInternalError`/`BadTimeout` members* — additive on the enum, but it
+  ripples a Commons contract consumed by every sink implementation and driver publish path, for a
+  sub-code no consumer acts on. Contained follow-up if a real client requirement appears.
+- *Carry a raw `uint` status through `AttributeValueUpdate`/`IOpcUaAddressSpaceSink.WriteValue`* — the
+  precise-parity option, but it changes an interface with four implementations and re-runs the
+  F10b/DeferredAddressSpaceSink forwarding trap for zero operator value. Rejected.
+- Exception: the **historize snapshot** takes raw `uint` status already (`DataValueSnapshot`), so there
+  the bridge records exact dormant-engine parity: `0x80020000u /* BadInternalError */` on Bad results
+  (the dormant engine historizes its Bad snapshots unconditionally at `VirtualTagEngine.cs:346-348`;
+  we mirror that).
+
+*(b) Publish cadence — once per transition, not per failure.* Every publish is a sink write +
+`ClearChangeMasks` → client notification on the pinned OPC UA dispatcher. A failing script on a hot
+dependency would otherwise emit one identical Bad write per dependency change (each also paying the P2
+pool hop + 2 s worst-case actor block per S7). The actor tracks `_lastPublishedBad`; it publishes Bad on
+the Good→Bad (or initial→Bad) transition only, and on recovery it **force-publishes the next success even
+if the value equals the pre-failure value** — otherwise the value-dedup at `VirtualTagActor.cs:138`
+would leave the node Bad forever after a same-value recovery. (Script-log Warning entries stay
+per-failure, unchanged — that's the existing operator diagnostic and its volume is pre-existing.)
+
+*(c) Cold-start suppression — an inputs-ready gate, mirroring the dormant engine.* The live path has no
+`AreInputsReady` equivalent: a multi-dep script fails on the first dependency arrival (the missing dep's
+`GetTag(...).Value` is null → cast throws) until all deps have landed. Publishing Bad on those would
+make every fresh/respawned multi-dep VT flash `BadWaitingForInitialData → Bad → Good` on every deploy —
+operator-visible churn for healthy scripts. Worse, gating on "had a prior success in this child" is
+**insufficient for S13's headline scenario**: an *edited-now-broken* script respawns its child (plan
+change ⇒ stop+respawn at `VirtualTagHostActor.cs:109-118`), so the fresh child has no prior success and
+would never degrade — reintroducing stale-Good exactly where it matters most. The correct gate is the
+dormant engine's: **suppress Bad publishes until every `dependencyRefs` entry has been received at least
+once** (`_dependencyRefs.All(_dependencies.ContainsKey)`); after that, any failure degrades. A
+never-arriving dependency keeps the node at the materialiser's `BadWaitingForInitialData` (already Bad —
+acceptable, and per the report "acceptable" for never-succeeded scripts).
+
+*(d) Message/bridge shape — additive `Quality` on `EvaluationResult`.* Add
+`OpcUaQuality Quality = OpcUaQuality.Good` as a trailing optional parameter on the
+`EvaluationResult` positional record: every existing construction and destructuring site compiles
+unchanged, and the message is a local `Context.Parent.Tell` (no wire serialization concern). The bridge
+replaces the hard-coded `OpcUaQuality.Good` with `result.Quality`, and maps the historize status
+(`Good→0u`, else `0x80020000u`). **The dependency mux is untouched** — quality flows child→parent→publish
+actor; the mux only fans values *into* children (`DependencyValueChanged` is unchanged), so no mux
+registration, routing, or message contract moves.
+
+On failure the Bad `EvaluationResult` carries the **last-known value** (`_lastValue` if `_hasLastValue`,
+else `null`) with the triggering `msg.TimestampUtc` — clients see the stale value explicitly flagged Bad
+(the report's "publish the existing value with Bad quality"), rather than the dormant engine's
+null-value choice; either is defensible, this one preserves more information for trend displays.
+
+**Implementation steps:**
+
+1. `Runtime/VirtualTags/VirtualTagActor.cs`:
+   - `using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;`
+   - `public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);`
+   - Add fields `private bool _lastPublishedBad;` and a helper
+     `private bool AllDependenciesArrived() => _dependencyRefs.All(_dependencies.ContainsKey);`
+     (refs list is small; called once per failure — no caching needed).
+   - Extract the failure tail shared by the evaluator-threw catch (`:115-121`) and the `!Success` branch
+     (`:123-128`) into `private void OnEvaluationFailed(string reason, string level, DateTime timestampUtc)`:
+     keep the existing metric + `PublishLog`, then
+     `if (_lastPublishedBad || !AllDependenciesArrived()) return;` else
+     `_lastPublishedBad = true; Context.Parent.Tell(new EvaluationResult(_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaQuality.Bad));`
+   - Success path: change the dedup guard at `:138` to
+     `if (!_lastPublishedBad && _hasLastValue && Equals(_lastValue, result.Value))` and set
+     `_lastPublishedBad = false;` alongside `_hasLastValue = true;` before the Good publish. The
+     `NoChange` short-circuit (`:132-136`) stays *above* the dedup and does **not** touch
+     `_lastPublishedBad` (NoChange = "no fresh value", not a recovery).
+   - Update the class summary: failures now degrade quality once per transition.
+2. `Runtime/VirtualTags/VirtualTagHostActor.cs` (`OnResult`, `:170-190`):
+   - `_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(nodeId, result.Value, result.Quality, result.TimestampUtc));`
+   - Historize: `var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;`
+     and pass `status` into the `DataValueSnapshot`.
+   - Update the `:179-180` comment (no longer hard-coded Good).
+3. Update `VirtualTagActorTests.Evaluator_failure_publishes_ScriptLogEntry_warning`: the trailing
+   `parent.ExpectNoMsg` becomes `parent.ExpectMsg()` with `Quality == Bad` (the test
+   constructs the actor with empty `dependencyRefs`, so the inputs-ready gate is vacuously satisfied —
+   `All` over an empty list is true).
+
+**Tests:** (all deterministic; probes only)
+- **Repro first (must FAIL at `f6eaa267`):** `Runtime.Tests` `VirtualTagHostActorTests.`
+  `Failing_script_after_success_degrades_node_quality_to_Bad` — host + real child + a scripted
+  fail-after-success `IVirtualTagEvaluator` stub; drive the child (captured via the mux probe's
+  `RegisterInterest` sender) with two `DependencyValueChanged`s; assert the publish probe receives
+  update 1 `(Good, 42)` then update 2 with `Quality == OpcUaQuality.Bad` carrying the last value. Uses
+  only current types (`AttributeValueUpdate` already has `Quality`), so it compiles today and times out
+  on the second `ExpectMsg` — the stale-Good repro.
+- `VirtualTagActorTests`: failure-after-success emits Bad `EvaluationResult` with last value;
+  second consecutive failure emits **nothing** (once-per-transition); recovery to the *same* value as
+  before the failure **does** publish (dedup bypass); evaluator-*threw* path (not just `Failure`) also
+  degrades; multi-dep cold-start failure with a missing dep publishes **no** Bad (inputs-ready gate);
+  after all deps arrive, failure publishes Bad.
+- `VirtualTagHostActorTests`: a Bad-quality `EvaluationResult` bridges to an `AttributeValueUpdate`
+  with `Quality == Bad`; a historized plan's Bad result records status `0x80020000u` (BadInternalError).
+- Wiring note (theme #1, "unit-green ≠ wired"): the repro test above IS the wiring assertion — it runs
+  the real host + real child + parent-Tell bridge, not a hand-delivered message. The remaining live wire
+  (publish actor → sink → SDK node) is pre-existing, already exercised by `OpcUaPublishActor` tests, and
+  covered by the live-`/run` gate below.
+
+**Effort:** S. **Risk:** Low-Medium. The behavioral deltas are (1) parents now receive Bad results —
+only `VirtualTagHostActor` parents children in production, and it handles the new field natively; (2) a
+node that used to freeze Good now goes Bad on persistent failure — the intended, strictly-safer change;
+(3) one existing test's expectation inverts (deliberate, task-tracked). Blast radius = VT value path
+only; alarms/drivers untouched.
+
+---
+
+## 2. S12 — MEDIUM — Apply-boundary `ClearCompiledScripts` races in-flight child evaluations
+
+**Restatement:** The host's per-apply `Clear()` disposes cached evaluators while unchanged children keep
+evaluating on other dispatcher threads. A child that fetched its evaluator (`GetOrCompile`,
+`RoslynVirtualTagEvaluator.cs:69`) before the clear hits the disposed guard on run
+(`ScriptEvaluator.cs:180`) → `ObjectDisposedException` → generic catch → `Failure` → silently dropped
+update (self-heals only on the tag's *next* dependency change — a long staleness window for slow tags).
+Secondary: a stopping child can recompile its stale script into the fresh cache for one generation.
+
+**Verification:** Confirmed (see summary). The cache itself is blameless — its value-scoped `TryRemove`
+protects concurrent `GetOrCompile` re-adds; the gap is clear-vs-*use* of an already-handed-out
+evaluator. The `ScriptedAlarmEngine` mirror is inexact exactly as the report says (gate-held clear after
+subscription teardown, `ScriptedAlarmEngine.cs:199-209`).
+
+**Root cause:** `IScriptCacheOwner.ClearCompiledScripts` was wired at the apply boundary (U3) without a
+coordination primitive between the host actor's clear and child actors' fetch→run sequence — the
+`Task.Run` hop inside `TimedScriptEvaluator` guarantees a real window between fetch and the disposed
+check.
+
+**Proposed design — catch-`ObjectDisposedException`, re-fetch, retry once (in the adapter).** The
+recompile is idempotent (same source → fresh evaluator in the *current* cache generation), the retry is
+entirely local to `RoslynVirtualTagEvaluator.Evaluate`, and it converts the dropped update into a
+correct evaluation against the post-clear generation. If a *second* clear lands mid-retry (two applies
+inside one evaluation — not a realistic cadence), the second `ObjectDisposedException` falls through to
+the existing `Failure` path — same as today's worst case, now with an S13 Bad-quality publish instead of
+silence. Alternatives rejected:
+- *`ReaderWriterLockSlim` (children read-lock fetch→run; clear write-locks)* — blocks the **host actor**
+  for up to a full in-flight timeout budget (2 s) during every apply, and adds lock traffic to the hot
+  path, to prevent a race the retry heals for free. Rejected.
+- *Per-generation cache instances with deferred dispose of the old generation* — disposing the old
+  generation safely still requires knowing when in-flight evaluations finish, i.e. ref-counting — the
+  same coordination problem with more machinery. Rejected.
+- *Move the clear after the stop/respawn reconciliation* — doesn't close the race for **unchanged**
+  children (the common case), and the stale-recompile wrinkle survives either way because `Context.Stop`
+  is asynchronous. Rejected as a fix; the wrinkle stays **accepted + documented** (bounded to one
+  generation, released at the next apply-with-changes; P7's gate also means no-op redeploys no longer
+  create the window at all).
+
+**Implementation steps** (`Host/Engines/RoslynVirtualTagEvaluator.cs`, the compiled path `:66-121`):
+
+1. Restructure fetch+run into a bounded loop (`for (var attempt = 0; ; attempt++)`):
+   - fetch: `evaluator = _cache.GetOrCompile(expression);` — keep the existing
+     `CompilationErrorException` / `ScriptSandboxViolationException` / generic catches on the fetch
+     (unchanged behavior). A disposed **cache** here means the whole evaluator is being shut down —
+     `GetOrCompile` throws `ObjectDisposedException`, which the new handler must NOT retry: check
+     `_disposed` and return `VirtualTagEvalResult.Failure("evaluator disposed")`.
+   - run: existing `TimedScriptEvaluator` + `GetAwaiter().GetResult()` block, adding **before** the
+     generic catch:
+     ```csharp
+     catch (ObjectDisposedException) when (!_disposed && attempt == 0)
+     {
+         // S12: the apply-boundary ClearCompiledScripts disposed the evaluator between our
+         // GetOrCompile and the run's disposed-guard. Re-fetch — GetOrCompile recompiles the same
+         // source into the CURRENT cache generation — and retry once. A second disposal mid-retry
+         // (two applies inside one evaluation) falls through to the generic failure path.
+         continue;
+     }
+     ```
+   - `catch (ObjectDisposedException)` (second hit, or `_disposed`) → `Failure("evaluator disposed during evaluation")`.
+2. Update the class doc: the apply-boundary clear is retry-safe for in-flight evaluations; the
+   one-generation stale-recompile wrinkle for stopping children is the documented residual.
+
+**Tests** (`Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`):
+- `Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed` — one evaluator; task A loops N
+  (≈300) compiled (non-passthrough) evaluations of the same expression; task B loops
+  `ClearCompiledScripts()` with `Thread.Yield()` between calls until A finishes; whole test
+  `WaitAsync`-bounded (30 s). Assert **every** result `Success == true` (and specifically no `Reason`
+  containing "disposed"). *Determinism note:* the pre-fix failure is probabilistic per iteration (the
+  window is fetch → `Task.Run`-hopped disposed-guard, widened by pool queuing), but at N=300 racing
+  clears it reproduces reliably in practice; post-fix it is deterministic-green by construction (the
+  retry is exhaustive for a single concurrent clear). This is the report's prescribed
+  "concurrent clear-during-evaluate test".
+- `Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds` — deterministic sequential guard:
+  evaluate, clear, evaluate same source again → Success both times, cache Count back to 1 (uses the
+  existing `CompiledCacheCount` reflection helper).
+- Existing `Evaluate_after_dispose_returns_Failure` still green (the `_disposed` short-circuit at `:51`
+  is untouched; the new ODE handler's `!_disposed` filter keeps shutdown semantics).
+
+**Effort:** S (the report's "10-line fix" is accurate; the loop restructure is the only care point).
+**Risk:** Low. Happy path unchanged (attempt 0, no exception); passthrough fast-path never enters the
+loop. Blast radius = one method in the Host adapter.
+
+---
+
+## 3. P7 — LOW — Unconditional per-apply cache flush forces full VT recompilation on every deploy
+
+**Restatement:** `OnApply` clears the compile cache on **every** `ApplyVirtualTags`
+(`VirtualTagHostActor.cs:92`) — including a byte-identical redeploy that stops/spawns nothing. Every VT
+script then recompiles on its next evaluation, each paying the still-open P1 full-BCL-reference-set
+rebuild (5-20 ms+ per script). Amplifies P1; also each unnecessary clear is an unnecessary S12 race
+window.
+
+**Verification:** Confirmed (see summary). The host already holds last generation's plans in
+`_planByVtag`; `EquipmentVirtualTagPlan.Expression` participates in value equality. The existing wiring
+guard asserts the *current* (unconditional) behavior on an identical redeploy and must be rewritten.
+
+**Root cause:** The U3 fix chose the simplest correct lifecycle ("clear per generation", mirroring the
+alarm engine) without consulting the plan diff the same method computes a few lines later.
+
+**Proposed design — clear only when the deployed *expression set* changed.** At the top of `OnApply`,
+compare the desired expression set against the previous generation's
+(`_planByVtag.Values.Select(p => p.Expression)`), and clear only on inequality:
+
+```csharp
+var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
+var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
+if (!newExpressions.SetEquals(prevExpressions))
+{
+    (_evaluator as IScriptCacheOwner)?.ClearCompiledScripts();
+}
+```
+
+Decisions inside this shape:
+- **Expression set, not full-plan set:** the cache is keyed by source hash only, so a rename /
+  FolderPath move / Historize toggle (which respawns the child) does not need a recompile — comparing
+  full plans would clear on those spuriously. Two vtags sharing one expression are handled naturally
+  (set semantics: the expression survives while any plan still uses it).
+- **Any-change clears everything** (not per-source eviction): editing 1 of 200 scripts still recompiles
+  200. Accepted per the report ("cheap to leave as-is at current scale; fix alongside P1") — the
+  headline P7 cost is the **no-op redeploy**, which this eliminates entirely. Rejected alternative: a
+  `CompiledScriptCache.RetainOnly(IEnumerable sources)` API — precise, but new Core.Scripting
+  surface + tests for a Low; note it as the follow-up shape if P1's memoization doesn't land first.
+- **First apply after actor (re)start:** `_planByVtag` is empty → sets differ → clear fires (harmless:
+  the singleton evaluator's cache may hold a prior incarnation's entries; clearing converges state).
+- **Interaction with S12 (design question d):** independent and composing — the gate reduces clear
+  *frequency* (no-op redeploys stop racing entirely), the retry remains the correctness backstop for
+  genuine changes. The gate must NOT be treated as the S12 fix: a real script edit still clears while
+  unchanged children evaluate.
+- **Interaction with S13:** none (quality path doesn't touch the cache).
+
+**Implementation steps:**
+1. Replace `VirtualTagHostActor.cs:88-92` (comment + unconditional call) with the guarded block above +
+   a comment explaining the expression-set gate (and that `_planByVtag` is "the previous generation" at
+   this point — the method rebuilds it at the end).
+2. Rewrite the wiring guard `ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation` →
+   `ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes`: apply plan A
+   (ClearCount 1) → re-apply identical plan A (ClearCount **still 1**) → apply plan A′ with an edited
+   `Expression` (ClearCount 2) → re-apply with a vtag removed but its expression still used by another
+   vtag (ClearCount still 2). Keeps the load-bearing "the host actually calls it" wiring assertion while
+   pinning the new gate.
+
+**Tests:** the rewritten wiring guard above (in `Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`,
+using the existing `CacheOwningStubEvaluator` spy). New-behavior red state: the *identical-redeploy*
+step fails on current code (ClearCount is 2, expected 1).
+
+**Effort:** S. **Risk:** Low. The only hazard is under-clearing (a stale ALC surviving a deploy whose
+expression set is unchanged) — by construction those entries are exactly the still-deployed sources, so
+nothing stale survives. Blast radius = one method + one test.
+
+---
+
+## Live-`/run` verification (after all tasks green)
+
+Deterministic units + wiring guards above are necessary but not sufficient — **unit-green ≠ wired**
+(the F10b lesson). Close with the docker-dev rig (login disabled; do it yourself, no user sign-in):
+
+1. Rebuild **both** `central-1` and `central-2` images (`:9200` round-robins them) and bring
+   `docker-dev/docker-compose.yml` up.
+2. On `/uns` (AdminUI `http://localhost:9200`), author a VirtualTag on a rig equipment with a script
+   that succeeds against live deps (e.g. `return (double)ctx.GetTag("").Value * 2;`), deploy
+   (`POST :9200/api/deployments`, `X-Api-Key`), and confirm via Client.CLI:
+   `dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n ""`
+   → value present, StatusCode Good.
+3. Edit the script to a guaranteed runtime throw (e.g. `int z = 0; return 1 / z;`), redeploy, wait one
+   dependency change → Client.CLI `read` again → **StatusCode Bad** (0x80000000), value = last-known.
+   This exercises the exact S13 headline path (edited-broken script ⇒ respawned child ⇒ inputs-ready
+   gate ⇒ Bad publish) end to end through the SDK node.
+4. Fix the script back, redeploy → `read` returns Good with a fresh value (recovery + dedup-bypass live).
+5. P7 spot-check: redeploy the identical config; central logs show no recompile burst (and the S12
+   stress guard covers the race in CI).
+
+---
+
+## Task breakdown
+
+Branch: `fix/archreview-r2-03-vt-failure-quality` off `master@f6eaa267`. Tasks are ≤5 min each;
+TDD-ordered (repro/red test precedes each implementation). Test commands run from the repo root.
+
+---
+
+### R2-03-T1 — S13 stale-Good repro test (RED — must fail on current code)
+
+**Classification:** test-first repro (deterministic unit, host-level wiring shape)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7 (different test file); everything else depends on this landing red first.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
+
+1. Add a `FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator` private stub (call 1 → `Ok(42.0)`,
+   thereafter → `Failure("boom")`; interlocked counter).
+2. Add test `Failing_script_after_success_degrades_node_quality_to_Bad`: host with publish + mux probes
+   and the stub; apply one plan (deps `["a"]`); capture the child via
+   `mux.ExpectMsg()` + `mux.LastSender`; tell the child
+   `DependencyValueChanged("a", 1, ts1)` → publish probe expects `AttributeValueUpdate` with
+   `Quality == OpcUaQuality.Good`, `Value == 42.0`; tell `DependencyValueChanged("a", 2, ts2)` →
+   expect a second `AttributeValueUpdate` with `Quality == OpcUaQuality.Bad` and `Value == 42.0`
+   (last-known) and `TimestampUtc == ts2`.
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Failing_script_after_success_degrades_node_quality_to_Bad"`
+   → **expected: FAIL** (timeout on the second ExpectMsg — the stale-Good bug, verbatim).
+
+Commit: `test(runtime): red repro — VT script failure after success leaves node stale-Good (02/S13)`
+
+---
+
+### R2-03-T2 — EvaluationResult.Quality + bridge pass-through + historize parity
+
+**Classification:** implementation (additive message field + bridge)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7/T8 (disjoint files). Blocked by T1 (repro must be red first).
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` (record only),
+`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
+
+1. RED: add bridge tests `Bad_quality_EvaluationResult_is_bridged_as_Bad` (tell the host a synthetic
+   `EvaluationResult(..., Quality: OpcUaQuality.Bad)` → publish probe's `AttributeValueUpdate.Quality`
+   is Bad) and `Historized_Bad_result_is_recorded_with_BadInternalError_status` (PlanH historize:true;
+   Bad result → `CapturingHistoryWriter` snapshot `StatusCode == 0x80020000u`). These require the record
+   change to compile — so within this task: add
+   `OpcUaQuality Quality = OpcUaQuality.Good` to `EvaluationResult` (+ `using ...Commons.OpcUa;`) FIRST,
+   then run the two tests:
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Bad_quality_EvaluationResult_is_bridged_as_Bad|FullyQualifiedName~VirtualTagHostActorTests.Historized_Bad_result_is_recorded_with_BadInternalError_status"`
+   → **expected: FAIL** (bridge still hard-codes Good / `0u`).
+2. GREEN: `OnResult` — pass `result.Quality` into `AttributeValueUpdate`; historize status
+   `result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */`;
+   fix the stale comments at `:179-188`. Re-run the filter → **PASS**. Also run the full host-actor
+   class (`--filter "FullyQualifiedName~VirtualTagHostActorTests"`) — all pre-existing tests
+   (Good-path bridging, historize Good `0u`) must stay green, proving the default keeps byte-parity.
+
+Commit: `feat(runtime): EvaluationResult carries OpcUaQuality; VT bridge publishes it + historizes BadInternalError on Bad (02/S13)`
+
+---
+
+### R2-03-T3 — VirtualTagActor failure→Bad transition (turns T1 green)
+
+**Classification:** implementation (actor behavior)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7/T8. Blocked by T2.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
+
+1. Implement per design §1(b)/(c)/(d): `_lastPublishedBad` field, `AllDependenciesArrived()`,
+   `OnEvaluationFailed(...)` shared by the throw-catch and `!Success` branch (metric + script-log
+   preserved per-failure; Bad publish once per transition, gated on inputs-ready, carrying
+   `_hasLastValue ? _lastValue : null` + `msg.TimestampUtc`); success path resets `_lastPublishedBad`
+   and bypasses the value-dedup when recovering; class-summary xmldoc updated.
+2. Update `Evaluator_failure_publishes_ScriptLogEntry_warning`: replace the trailing
+   `parent.ExpectNoMsg(...)` with
+   `parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad)` —
+   the deliberate behavioral inversion (empty `dependencyRefs` ⇒ inputs-ready gate vacuously true).
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Failing_script_after_success_degrades_node_quality_to_Bad"`
+   → **expected: PASS** (T1 repro green).
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests"`
+   → **expected: PASS** (incl. the updated failure test).
+
+Commit: `fix(runtime): VT script failure/timeout degrades node quality to Bad once per transition (02/S13)`
+
+---
+
+### R2-03-T4 — S13 transition-semantics unit guards
+
+**Classification:** tests (deterministic units pinning the state machine)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T5, T7/T8. Blocked by T3.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
+
+Add (scriptable stub evaluator with a queue of results):
+1. `Second_consecutive_failure_does_not_publish_a_second_Bad` — fail, fail → exactly one Bad
+   `EvaluationResult` at the parent (`ExpectMsg` then `ExpectNoMsg`).
+2. `Recovery_to_the_same_value_republishes_Good_after_Bad` — Ok(42), fail, Ok(42) → three parent
+   messages; the third is `Quality == Good`, `Value == 42` (dedup bypass — this is the guard against the
+   node staying Bad forever after a same-value recovery).
+3. `Evaluator_throw_also_degrades_quality` — evaluator that throws (not `Failure`) → Bad
+   `EvaluationResult` published (the `:115-121` path).
+4. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests"`
+   → **expected: PASS** (all should pass immediately if T3 is correct; any red here is a T3 bug, fix in
+   place).
+
+Commit: `test(runtime): pin VT Bad-quality transition semantics — once-per-transition, same-value recovery, throw path (02/S13)`
+
+---
+
+### R2-03-T5 — S13 cold-start suppression guard (inputs-ready gate)
+
+**Classification:** tests (deterministic unit pinning the gate)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T4, T7/T8. Blocked by T3.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
+
+1. `Cold_start_failure_with_missing_dependency_publishes_no_Bad` — actor with
+   `dependencyRefs: ["a","b"]`, always-failing evaluator; send `DependencyValueChanged("a", ...)` only →
+   `parent.ExpectNoMsg` (node stays at the materialiser's `BadWaitingForInitialData`; no false Bad
+   flash during deploy/respawn warm-up).
+2. `Failure_after_all_dependencies_arrived_publishes_Bad` — same actor; send `"a"` then `"b"` → the
+   second change (all refs now seen) publishes exactly one Bad `EvaluationResult`.
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests.Cold_start_failure_with_missing_dependency_publishes_no_Bad|FullyQualifiedName~VirtualTagActorTests.Failure_after_all_dependencies_arrived_publishes_Bad"`
+   → **expected: PASS**.
+
+Commit: `test(runtime): pin VT inputs-ready gate — no Bad flash before all dependencies arrive (02/S13)`
+
+---
+
+### R2-03-T6 — S12 sequential clear-then-recompile guard (RED-able) 
+
+**Classification:** test-first (deterministic unit)
+**Estimated implement time:** 3 min
+**Parallelizable with:** T1-T5 (different project/file).
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`
+
+1. Add `Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds`: evaluate a compiled expression,
+   `ClearCompiledScripts()`, evaluate the same source again → both `Success`, `CompiledCacheCount == 1`
+   after the second call. (Green on current code — this pins the sequential contract the T8 retry loop
+   must not regress; the restructure moves the fetch inside a loop.)
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests.Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds"`
+   → **expected: PASS** (pre-fix baseline pin).
+
+Commit: `test(host): pin sequential clear-then-recompile contract of RoslynVirtualTagEvaluator (02/S12 baseline)`
+
+---
+
+### R2-03-T7 — S12 concurrent clear-vs-evaluate stress guard (RED — fails on current code)
+
+**Classification:** test-first repro (concurrency stress; probabilistic-per-iteration, reliable at volume)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`
+
+1. Add `async Task Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed`: one evaluator
+   (generous 5 s timeout so the budget never fires); `Task.Run` loop A doing 300 sequential
+   `Evaluate("vt-race", "return (int)ctx.GetTag(\"a\").Value + 1;", …)` calls collecting results;
+   concurrent loop B calling `ClearCompiledScripts()` + `Thread.Yield()` until A completes; whole test
+   awaited with `WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)` (regression
+   fails, never hangs — the U2 test's pattern). Assert every result `Success` shouldBeTrue with
+   `result.Reason` as the message (surfaces "script threw: Cannot access a disposed object" on red).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed"`
+   → **expected: FAIL** on current code (disposed-reason failures observed within 300 racing
+   iterations; the window is GetOrCompile-return → Task.Run-hopped disposed guard). If a run happens to
+   pass, bump iterations — do not weaken the assertion.
+
+Commit: `test(host): red repro — apply-boundary cache clear races in-flight VT evaluation into ObjectDisposedException (02/S12)`
+
+---
+
+### R2-03-T8 — S12 retry-once on ObjectDisposedException (turns T7 green)
+
+**Classification:** implementation (adapter hardening)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T5. Blocked by T6 + T7.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`
+
+1. Restructure the compiled path per design §2: `for (var attempt = 0; ; attempt++)` around
+   fetch (`GetOrCompile` — existing catch chain preserved) + run (`TimedScriptEvaluator` block), adding
+   `catch (ObjectDisposedException) when (!_disposed && attempt == 0) { continue; }` before the generic
+   catch, and `catch (ObjectDisposedException)` → `Failure("evaluator disposed during evaluation")` for
+   the shutdown / double-clear case. Update the class xmldoc (retry-safe vs apply-boundary clear;
+   one-generation stale-recompile residual documented).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests"`
+   → **expected: PASS** (T7 stress green; T6 sequential pin green; all pre-existing U2/U3/passthrough
+   tests green — proves the loop restructure didn't disturb timeout, cache, or fast-path semantics).
+
+Commit: `fix(host): retry VT evaluation once on ObjectDisposedException from the apply-boundary cache clear (02/S12)`
+
+---
+
+### R2-03-T9 — P7 conditional-clear wiring guard (RED — fails on current code)
+
+**Classification:** test-first (rewrites an existing wiring guard — deliberate behavior change)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7/T8. Blocked by T3 (same test file churn as T1-T5; serialize after S13 lands).
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
+
+1. Rewrite `ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation` →
+   `ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes` (keep the
+   `CacheOwningStubEvaluator` spy; keep the "the host actually calls it" wiring-assertion role — update
+   the xmldoc to say the clear is now gated, citing 02/P7):
+   apply plan A (`ClearCount == 1`) → re-apply value-identical plan A (**`ClearCount == 1`**) → apply
+   plan A with an edited `Expression` (`ClearCount == 2`) → apply two vtags sharing one expression, then
+   remove one of them (expression set unchanged ⇒ count unchanged).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes"`
+   → **expected: FAIL** on current code (identical-redeploy step observes `ClearCount == 2`).
+
+Commit: `test(runtime): red — apply-boundary compile-cache clear must be gated on a changed expression set (02/P7)`
+
+---
+
+### R2-03-T10 — P7 expression-set gate (turns T9 green)
+
+**Classification:** implementation (host-actor apply path)
+**Estimated implement time:** 3 min
+**Parallelizable with:** — (same method as T2's file; do after T9). Blocked by T9.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`
+
+1. Replace `OnApply`'s unconditional clear (`:88-92`) with the guarded block from design §3
+   (`HashSet.SetEquals` over `msg.Plans` vs `_planByVtag.Values` expressions; ordinal comparer), with a
+   comment covering: `_planByVtag` is the previous generation at this point; rename/Historize-only
+   changes don't clear (cache is source-keyed); first-apply-after-restart clears by construction;
+   the S12 retry — not this gate — is the in-flight-race fix.
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests"`
+   → **expected: PASS** (T9 guard green + every other host-actor test green).
+
+Commit: `perf(runtime): gate the VT apply-boundary compile-cache clear on a changed expression set (02/P7)`
+
+---
+
+### R2-03-T11 — Full-suite sweep + STATUS/report bookkeeping
+
+**Classification:** verification + docs
+**Estimated implement time:** 5 min
+**Parallelizable with:** —. Blocked by T1-T10.
+**Files:** `archreview/plans/STATUS.md`, `archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json`
+
+1. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` → **expected: PASS** (whole project).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests"`
+   → **expected: PASS**.
+3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean (analyzer OTOPCUA0001 + xmldoc warnings unchanged).
+4. Record S13/S12/P7 as fixed-on-branch in `STATUS.md` (Re-review 2026-07-12 section) + tick the
+   tasks.json.
+
+Commit: `docs(archreview): record R2-03 (02/S13+S12+P7) fixed on branch; full VT suites green`
+
+---
+
+### R2-03-T12 — Live-`/run` gate on docker-dev (fail→Bad→recover, end to end)
+
+**Classification:** live verification (docker-dev rig; no user sign-in needed — login disabled)
+**Estimated implement time:** 15-20 min (rig rebuild dominates)
+**Parallelizable with:** —. Blocked by T11.
+**Files:** none (verification only; findings, if any, loop back as new tasks)
+
+Follow the "Live-`/run` verification" section above: rebuild **both** central images; author a working
+VT → Client.CLI `read` Good; edit it to a runtime throw → redeploy → after one dependency change,
+Client.CLI `read` shows **StatusCode Bad** with the last-known value (this is the edited-broken-script
+respawn path — the S13 headline — through the real mux/child/bridge/publish-actor/SDK-node chain); fix
+→ redeploy → `read` Good with a fresh value. Spot-check an identical redeploy produces no recompile
+burst in central logs (P7). Do NOT merge on unit-green alone — this is the wiring bar.
+
+Commit: none (verification). If the rig exposes a gap, open follow-up tasks before merge.
+
+---
+
+## Task dependency summary
+
+```
+T1 (S13 red repro) ──► T2 (record+bridge) ──► T3 (actor fix, T1 green) ──► T4, T5 (guards)
+                                                                └────────► T9 (P7 red) ──► T10 (P7 green)
+T6 (S12 pin) ─┐
+T7 (S12 red) ─┴──► T8 (S12 retry, T7 green)
+T4, T5, T8, T10 ──► T11 (sweep + docs) ──► T12 (live /run gate)
+```
+
+**Overall effort:** Small (≈2-3 h implementation + the rig session), matching the OVERALL table's
+estimate for action item #3. **Overall risk:** Low-Medium, concentrated in S13's deliberate behavior
+change (nodes that used to freeze Good now go Bad — the point of the fix) and the one inverted legacy
+test expectation, both explicitly task-tracked.
diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
new file mode 100644
index 00000000..86bfffdd
--- /dev/null
+++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
@@ -0,0 +1,78 @@
+{
+  "planPath": "archreview/plans/R2-03-vt-failure-quality-plan.md",
+  "tasks": [
+    {
+      "id": "R2-03-T1",
+      "subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "R2-03-T2",
+      "subject": "Add OpcUaQuality Quality (default Good) to VirtualTagActor.EvaluationResult; bridge passes result.Quality into AttributeValueUpdate; historize maps Bad to 0x80020000u BadInternalError (RED bridge tests then GREEN)",
+      "status": "pending",
+      "blockedBy": ["R2-03-T1"]
+    },
+    {
+      "id": "R2-03-T3",
+      "subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)",
+      "status": "pending",
+      "blockedBy": ["R2-03-T2"]
+    },
+    {
+      "id": "R2-03-T4",
+      "subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades",
+      "status": "pending",
+      "blockedBy": ["R2-03-T3"]
+    },
+    {
+      "id": "R2-03-T5",
+      "subject": "S13 cold-start suppression guards: missing-dep failure publishes no Bad; failure after all deps arrived publishes Bad (inputs-ready gate)",
+      "status": "pending",
+      "blockedBy": ["R2-03-T3"]
+    },
+    {
+      "id": "R2-03-T6",
+      "subject": "S12 sequential pin: Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds (green baseline the T8 retry-loop restructure must not regress)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "R2-03-T7",
+      "subject": "S12 concurrent stress repro (RED): 300 compiled evaluations racing ClearCompiledScripts, WaitAsync-bounded, assert zero disposed-reason failures",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "R2-03-T8",
+      "subject": "S12 fix: retry-once on ObjectDisposedException in RoslynVirtualTagEvaluator.Evaluate (re-fetch via GetOrCompile; no retry when _disposed or second hit); update xmldoc (turns T7 green)",
+      "status": "pending",
+      "blockedBy": ["R2-03-T6", "R2-03-T7"]
+    },
+    {
+      "id": "R2-03-T9",
+      "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
+      "status": "pending",
+      "blockedBy": ["R2-03-T3"]
+    },
+    {
+      "id": "R2-03-T10",
+      "subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)",
+      "status": "pending",
+      "blockedBy": ["R2-03-T9"]
+    },
+    {
+      "id": "R2-03-T11",
+      "subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping",
+      "status": "pending",
+      "blockedBy": ["R2-03-T4", "R2-03-T5", "R2-03-T8", "R2-03-T10"]
+    },
+    {
+      "id": "R2-03-T12",
+      "subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy",
+      "status": "pending",
+      "blockedBy": ["R2-03-T11"]
+    }
+  ],
+  "lastUpdated": "2026-07-12"
+}
diff --git a/archreview/plans/R2-04-failure-visibility-plan.md b/archreview/plans/R2-04-failure-visibility-plan.md
new file mode 100644
index 00000000..1278a33d
--- /dev/null
+++ b/archreview/plans/R2-04-failure-visibility-plan.md
@@ -0,0 +1,539 @@
+# Design + Implementation Plan — R2-04 Failure-Visibility Trio
+
+- **Source reports:** `archreview/01-core-composition.md` §S-1 · `archreview/06-gateway-integrations.md` §S-1 · `archreview/03-server-runtime.md` §S4 (2026-07-12 re-review, working-tree versions)
+- **Round-1 designs consumed:** `plans/01-core-composition-plan.md` §1 (S-1) · `plans/06-gateway-integrations-plan.md` §P0-A (S-1) · `plans/03-server-runtime-plan.md` §S4
+- **Plan verified against tree at:** master `f6eaa267` (clean) — every anchor re-opened and re-checked 2026-07-12
+- **Scope:** OVERALL prioritized action item **#4** — "Failure-visibility trio: `AddressSpaceApplyOutcome` failure field; Galaxy fail-closed writes; primary-gate default-deny" (01 S-1, 06 S-1, 03 S4). Carried from round 1; no branch has ever targeted these.
+- **Cross-cutting requirement (OVERALL theme 3, "optimistic success"):** every swallowed failure must surface somewhere operator-visible. Concrete pick per finding: **01/S-1** → Error-level Serilog + a new dedicated meter (`otopcua.opcua.apply.failed`); **06/S-1** → Bad write status that feeds the existing #5 node-revert + Part 8 audit event, plus two new Galaxy meters (`galaxy.writes.advise_failed` / `galaxy.writes.unconfirmed`); **03/S4** → the rejected write rides the existing optimistic-write revert + Bad-blip + `AuditWriteUpdateEvent` surface, plus a new denial meter (`otopcua.redundancy.primary_gate_denied`) and Warning logs.
+
+## Verification summary
+
+All three findings **CONFIRMED at `f6eaa267`** — none stale, none partially fixed. Anchor drift status:
+
+| Finding | Report anchor | Status at `f6eaa267` |
+|---|---|---|
+| 01/S-1 | `AddressSpaceApplier.cs:148-153` (rebuilt=true after SafeRebuild) | **Minor drift**: the structural block is now `:150-154` (`SafeRebuild()` at `:152`, `rebuilt = true` at `:153`). Report also under-counts: there are **three** rebuilt-set sites — `:150-154` (structural), `:191` (surgical fallback `if (!allApplied) { SafeRebuild(); rebuilt = true; }`), `:196-198` (sink lacks surgical capability). All three must carry the failure flag. |
+| 01/S-1 | `AddressSpaceApplier.cs:373-383` (SafeRebuild), `:612-622` (SafeEnsureFolder/Variable), `:677-687` (SafeWriteAlarmCondition/SafeMaterialiseAlarmCondition), `:691` (outcome record) | **Exact.** Record is `AddressSpaceApplyOutcome(int RemovedNodes, int AddedNodes, int ChangedNodes, bool RebuildCalled)` at `:691-695`; construction sites `:80` (empty plan) and `:215` (main return). |
+| 01/S-1 consumer | `OpcUaPublishActor.cs:335` (Apply), `:338-354` (four Materialise passes), `:357-358` (Info log only), `:360-363` (catch-all) | **Exact** (round-1 plan refs still hold). Confirmed no `ApplyAck`/`DeploymentFailed` coupling: `DriverHostActor.cs:1218-1222` sends `ApplyAck(Applied)` **before** Telling `RebuildAddressSpace` — the deploy seals independently of apply health, exactly as reported. |
+| 06/S-1 | `GatewayGalaxyDataWriter.cs:163-169` (comment), `:234-243` (advise-failure fall-through), `:281-302` (TranslateReply) | **Exact** (the "value never reaches the galaxy" comment spans `:161-166`; advise-failure `TryRemove`+warn+proceed at `:234-243`; empty-statuses→`Good` at `:295-301`). Verified the Bad-status chain the fix relies on: `DriverInstanceActor.IsGoodStatus` (`:950`, top-2-bits check) → `WriteAttributeResult(false, "StatusCode=0x…")` (`:607-608`) → `NodeWriteResult` → `NodeWriteOutcome(false)` → `OtOpcUaNodeManager.RevertOptimisticWriteIfNeeded` (`:842-846`, `:1048`, `:1095`) — so a `Bad` return from the writer **does** fire the #5 revert + audit event today. |
+| 06/S-1 test constraint | round-1 plan: "fake session records calls" | **INFEASIBLE as written** — `GatewayGalaxyDataWriterTests.cs:17-99` documents that the `MxGatewaySession` SDK types are **sealed + internal-ctor and cannot be faked**; the existing 8 tests are cache-level only (`SeedHandleCachesForTest`). Resolved below with an internal `IMxWriteOps` seam. |
+| 03/S4 | `DriverHostActor.cs:977` (alarm-emit gate), `:1039` (write gate), `:1095` (ack gate), `OnRedundancyStateChanged :1126` | **Exact** (re-review refs are current). `_localRole` field is now at `:201` (round-1 plan said `:195`); the round-1 plan's gate refs (`:1018-1026`, `:1074-1083`, `:956-984`, `:1109-1116`) drifted +6…+17 lines — use `:1035-1043`, `:1091-1100`, `:955-1002`, `:1126-1133`. DPS subscribe to `redundancy-state` confirmed in `PreStart` (`:385-393`). The **same default-allow pattern also exists in `ScriptedAlarmHostActor.cs:314`** (scripted-alarm alerts-emit gate, `_localRole` at `:109`, `OnRedundancyStateChanged` `:327-338`) — not cited by the finding, brought in scope as a consistency extension (same policy helper). |
+
+Supporting facts verified: `OtOpcUaTelemetry` central meter (`Commons/Observability/OtOpcUaTelemetry.cs:19-63`, meter name `ZB.MOM.WW.OtOpcUa`); Galaxy driver meter name `ZB.MOM.WW.OtOpcUa.Driver.Galaxy` is a **public const on `EventPump`** (`EventPump.cs:36`) while `GalaxyTelemetry.cs` is ActivitySource-only (no Meter); `RuntimeActorTestBase` runs a real single-node Akka cluster (self-join, `ClusterActorRefProvider`, no roles) so `Cluster.Get` is safe in Runtime.Tests and the "driver"-role member count there is 0; role string `"driver"` per `Cluster/RoleParser.cs:7`; existing gate coverage in `DriverHostActorWriteRoutingTests.cs` asserts the exact reason string `"not primary"` on a Secondary (`:76-102`) and asserts a no-snapshot write is serviced (`:49-74`) — both must keep passing; `TwoNodeClusterHarness` exists in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/` for the delivery-path integration test; applier test fixtures `RecordingSink`/`ThrowingSink` at `AddressSpaceApplierTests.cs:1761/:1906` (ThrowingSink currently only supports `throwOnAlarmWrite`).
+
+### Round-1 open design choices — resolved in this plan
+
+1. **01/S-1 failure-tally exposure** (round-1 offered out-param vs instance counter): resolved to **return `int` from each `Materialise*` method** (option a) — no hidden state, the publish actor sums.
+2. **01/S-1 operator surface**: resolved to a **new dedicated counter `otopcua.opcua.apply.failed`** instead of round-1's `OpcUaSinkWrite`+`kind:"apply-failed"` tag — `OpcUaSinkWrite` is documented as "writes that landed", and a failure is not a landed write. Plus `_log.Error`. **No deploy abort / no ApplyAck change** (confirmed round-1's deferral: the ack is already sent before the rebuild message is even Told; deploy-status coupling belongs to a separate ControlPlane work item).
+3. **01/S-1 record-shape compatibility**: new fields are **defaulted trailing parameters** (`bool RebuildFailed = false, int FailedNodes = 0`) so both construction sites and every existing test compile unchanged.
+4. **06/S-1 status choice**: **confirmed round-1's `BadCommunicationError`** (Bad, not Uncertain) on advise failure — verified above that Bad feeds the #5 revert so the phantom-Good node is corrected. Empty-statuses stays `Good` **+ metered** (round-1's interim), because whether the gateway guarantees a status row on success is a gateway-contract question — deferred to the cross-repo `OnWriteComplete` correlation follow-up (`EventPump.cs:200-206` filters that event family out today).
+5. **06/S-1 testability**: round-1's fake-session test is impossible (sealed SDK types) — resolved with an **internal `IMxWriteOps` seam** (production adapter over `(MxGatewaySession, serverHandle)`; tests inject a fake). Advise failure is judged on **protocol status only** (current semantics preserved; interpreting MX status rows on an advise reply risks fail-closing healthy writes on an unverified contract — noted for the cross-repo follow-up).
+6. **03/S4 grace-window semantics** (reject vs queue vs reject-with-BadNotWritable): resolved to **reject**. Queueing is rejected outright: an operator write to an OT field device executed seconds after the operator issued it is surprise actuation, and OPC UA write semantics are synchronous. "Reject-with-BadNotWritable" does not fit the server's write pipeline: `OnWriteValue` returns optimistic Good and the async `NodeWriteOutcome(false)` fires the sanctioned self-correction surface — node reverts to prior value/status with a transient Bad-quality blip + a Part 8 `AuditWriteUpdateEvent` (`OtOpcUaNodeManager.cs:842-846`). The boot-window rejection uses a **distinct reason string `"not primary (role unknown)"`** so operators can tell a boot-window denial from a steady-state Secondary denial.
+7. **03/S4 membership signal**: confirmed round-1's cluster-member-count default (single-driver cluster ⇒ allow; ≥2 `driver`-role Up members ⇒ deny while role unknown), implemented behind an injectable `Func? driverMemberCountProvider` Props parameter (deterministic unit tests; the default reads `Cluster.Get(Context.System).State.Members` guarded by try/catch → 0 on non-cluster systems, preserving legacy/test harnesses).
+8. **03/S4 gate scope**: uniform policy across **all three** `DriverHostActor` sites (write, ack, alarm-emit) **and** `ScriptedAlarmHostActor`'s alerts-emit gate. For the emit gates the trade-off is: deny-on-unknown may drop an alerts fan-out publish during the ≤10 s boot window (bounded by the `RedundancyStateActor` heartbeat) vs. default-emit produces **duplicate cluster-wide alert rows historized durably into AVEVA**. Chosen: deny — the OPC UA condition write stays ungated (the node's own data is never lost; only the fleet-wide fan-out defers to the peer that knows it is Primary), and the double-row bug has real history (see memory: redundancy-state delivery was double-broken; single-row `/alerts` was an explicit verification criterion).
+
+---
+
+## Finding 1 — 01/S-1: Applier swallows every sink failure; deploy reports applied even when the address space is broken (High)
+
+### Restatement
+
+`AddressSpaceApplier` wraps every sink call in catch-all `Safe*` helpers that log-and-continue. `AddressSpaceApplyOutcome` carries only Added/Removed/Changed counts + `RebuildCalled` — no failure channel. If `_sink.RebuildAddressSpace()` throws, `rebuilt` is still reported `true` and `OpcUaPublishActor` logs the outcome at Info; the deployment seals (the `ApplyAck(Applied)` was already sent at `DriverHostActor.cs:1218` before the rebuild was even requested) while the running server holds a partially-materialised or entirely stale address space. Per-node `EnsureFolder`/`EnsureVariable`/`MaterialiseAlarmCondition` failures during the four materialise passes vanish into per-node Warnings.
+
+### Verification
+
+Confirmed (see summary table). Precisely:
+
+- `AddressSpaceApplier.cs:691-695` — outcome record, no failure fields; constructed at `:80` (empty plan) and `:215` (main return).
+- `:373-383` — `SafeRebuild` catches everything, returns nothing. Callers at `:152` / `:191` / `:196` all set `rebuilt = true` regardless.
+- `:612-616` / `:618-622` — `SafeEnsureFolder`/`SafeEnsureVariable` return `void`, swallow to Warning. `:677-681` / `:683-687` — same for the alarm-condition writes. The four `Materialise*` passes (`:393-415`, `:432-488`, `:543-580`, `:592-610`) and `MaterialiseDiscoveredNodes` (`:501-528`) have no failure output.
+- Consumer: `OpcUaPublishActor.HandleRebuild` (`:290-364`) — `Apply` at `:335`, four materialise calls `:338-354`, single `_log.Info` at `:357-358`, all inside a catch-all that logs (`:360-363`). Nothing downstream reads the outcome.
+- Note: the surgical branch (`:155-199`) already logs per-item `LogError` and **falls back to a full rebuild on any surgical failure** — a *recovered* failure. Only an unrecovered rebuild throw / node failure needs to surface.
+
+### Root cause
+
+The never-fail-a-deploy posture — correct for the detached hooks (`ProvisionHistorizedTags`, `FeedHistorizedRefs`, both deliberately fire-and-forget) — was applied uniformly to the structural materialisation that *is* the deploy's core contract. The outcome record has no channel to express failure, so the publish actor could not act even if it wanted to.
+
+### Proposed design
+
+Give the applier a truthful failure surface and route it to Error-level Serilog + a dedicated meter. **Visibility-only in v1**: no deploy abort, no ApplyAck change (the ack has already been sent by the time the publish actor runs; converting apply health into deploy status is ControlPlane-scoped follow-on work). A partially-materialised address space is still better than none; the goal is that an operator can *see* it happened.
+
+**Outcome shape (exact):**
+
+```csharp
+/// Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
+/// RebuildCalled means a rebuild was ATTEMPTED; RebuildFailed means the attempt threw (the sink's
+/// address space is stale/partial). FailedNodes counts swallowed per-node sink failures in Apply's
+/// own passes (removal condition writes). The Materialise* passes report their own failed-node
+/// tallies via their int returns — the publish actor sums.
+public sealed record AddressSpaceApplyOutcome(
+    int RemovedNodes,
+    int AddedNodes,
+    int ChangedNodes,
+    bool RebuildCalled,
+    bool RebuildFailed = false,
+    int FailedNodes = 0);
+```
+
+Defaulted trailing params keep `:80`, `:215`, and every existing test construction compiling.
+
+**Applier changes:**
+
+- `SafeRebuild()` → `private bool SafeRebuild()` returning `false` on catch (log stays `LogError`). All three call sites thread the flag: `if (structuralRebuild) { rebuildFailed = !SafeRebuild(); rebuilt = true; }` (`:150-154`), and the same at `:191` and `:196-198`.
+- `SafeEnsureFolder`/`SafeEnsureVariable`/`SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition` → return `bool` (false on catch; keep Warning logs).
+- `Apply` tallies its own removal-pass `SafeWriteAlarmCondition` failures into `FailedNodes` and returns `new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes)`.
+- The five materialise methods change `void` → `int` (count of failed sink calls in that pass). Source-compatible for any caller that ignores the return.
+
+**Publish-actor surfacing (`OpcUaPublishActor.HandleRebuild`):**
+
+```csharp
+var outcome = _applier.Apply(plan);
+_lastApplied = composition;
+
+var failedNodes = outcome.FailedNodes;
+failedNodes += _applier.MaterialiseHierarchy(composition);
+failedNodes += _applier.MaterialiseScriptedAlarms(composition);
+failedNodes += _applier.MaterialiseEquipmentTags(composition);
+failedNodes += _applier.MaterialiseEquipmentVirtualTags(composition);
+
+OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild"));
+if (outcome.RebuildFailed || failedNodes > 0)
+{
+    OtOpcUaTelemetry.OpcUaApplyFailed.Add(1,
+        new KeyValuePair("kind", outcome.RebuildFailed ? "rebuild" : "nodes"));
+    _log.Error(
+        "OpcUaPublish: address-space apply DEGRADED (correlation={Correlation}, rebuildFailed={RebuildFailed}, failedNodes={FailedNodes}, added={Added}, removed={Removed}, changed={Changed}) — the running address space may be stale or partial",
+        msg.Correlation, outcome.RebuildFailed, failedNodes,
+        outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes);
+}
+else
+{
+    _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
+        msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
+}
+```
+
+`HandleMaterialiseDiscovered` gets the same treatment for its single pass (Error + meter when the returned count > 0).
+
+**New instrument (`OtOpcUaTelemetry`):**
+
+```csharp
+/// Address-space apply passes that swallowed at least one sink failure (kind=rebuild|nodes).
+public static readonly Counter OpcUaApplyFailed =
+    Meter.CreateCounter("otopcua.opcua.apply.failed", unit: "{apply}",
+        description: "Apply/materialise passes with swallowed sink failures (kind=rebuild|nodes).");
+```
+
+**Alternatives considered (carried + re-affirmed from round 1):**
+- *Throw from `Apply` on rebuild failure* — rejected: the actor's catch (`:360-363`) would swallow it, it aborts the still-viable `Materialise*` passes, and it loses the counts.
+- *Full ApplyAck→DeploymentFailed wiring* — rejected for this pass: the ack is sent before the rebuild message (`DriverHostActor.cs:1218-1222`); re-sequencing deploy acknowledgment around apply health is a ControlPlane design change with its own blast radius. Tracked as a follow-on note, not here.
+- *Overload `OpcUaSinkWrite` with `kind:"apply-failed"`* — rejected in favor of a dedicated counter (semantic purity; a dashboard alerting on apply failures shouldn't parse a tag off a success counter).
+- *Alert row on `/alerts`* — rejected: the alerts topic is the **alarm** fan-out and is historized into AVEVA by `HistorianAdapterActor`; deploy-infrastructure failures would pollute durable alarm history.
+
+**`DeferredAddressSpaceSink` interplay:** no new `IOpcUaAddressSpaceSink` members are added (only the applier's own types change), so the reflection forwarding guard (`DeferredSinkForwardingReflectionTests`) is unaffected. Note for the record: a rebuild routed through a deferred sink **before the inner sink attaches** no-ops silently rather than throwing — that pre-attach window is startup-scoped and out of scope here.
+
+### Implementation steps
+
+1. `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — record + `Safe*` bool returns + three rebuilt sites + removal-pass tally + `Materialise*`/`MaterialiseDiscoveredNodes` int returns.
+2. `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs` — `OpcUaApplyFailed` counter.
+3. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` — sum + branch Error/meter in `HandleRebuild` and `HandleMaterialiseDiscovered`.
+
+### Tests
+
+- `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs` (new; extend `ThrowingSink` with `throwOnRebuild` / `throwOnEnsureVariable` knobs or a local fixture):
+  - rebuild throws ⇒ `RebuildCalled == true`, `RebuildFailed == true`;
+  - N `EnsureVariable` throws in `MaterialiseEquipmentTags` ⇒ return == N;
+  - happy path ⇒ `RebuildFailed == false`, `FailedNodes == 0`, all `Materialise*` return 0 (regression);
+  - removal-pass `WriteAlarmCondition` throw ⇒ `FailedNodes` counts it (upgrade of the existing `:105` ThrowingSink case).
+- `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs` (new, TestKit): wire a throwing sink + real applier through `PropsForTests`, drive `RebuildAddressSpace`, assert via `EventFilter.Error(...)` that the DEGRADED line fires, and via `MeterListener` that `otopcua.opcua.apply.failed` incremented; assert the happy path stays Info-only.
+- Full existing suites stay green: `AddressSpaceApplierTests` (construction sites untouched thanks to defaulted params).
+
+### Effort + Risk
+
+**Effort:** M. **Risk:** medium — public record + five public method signatures change (`void`→`int` is source-compatible; the record change is compile-guarded by defaults). Blast radius: OpcUaServer + Runtime + their tests. Classified high-risk (data-contract) in the task breakdown; land first and run the two full test projects.
+
+---
+
+## Finding 2 — 06/S-1: Galaxy write success is optimistic; a committed-write failure can never surface (High)
+
+### Restatement
+
+`GatewayGalaxyDataWriter.TranslateReply` defaults to `Good` when the reply's statuses array is empty, and the gateway's write execution is fire-and-forget past dispatch (the reply reflects command acceptance, not the COM-side commit). Worse: when `AdviseSupervisory` fails, the code logs a warning and **issues the raw Write anyway** — while the file's own comment (`:161-166`) states a non-advised raw Write "doesn't throw (reply looks OK) but the value never reaches the galaxy". That is a knowingly-lost write returned as `Good` to the OPC UA client, and because the status is Good, the #5 write-outcome self-correction (master `1d797c1c`) structurally never fires for Galaxy — the node keeps a phantom-Good value indefinitely.
+
+### Verification
+
+Confirmed exact at `f6eaa267` (see summary). Key chain fact re-verified: a `Bad` status returned from the writer **does** propagate — `GalaxyDriver.WriteAsync` (`GalaxyDriver.cs:785-803`) returns the writer's results verbatim → `DriverInstanceActor.HandleWriteAsync` (`:602-608`) maps non-Good to `WriteAttributeResult(false, "StatusCode=0x…")` → `DriverHostActor.HandleRouteNodeWrite` → `ActorNodeWriteGateway` → `NodeWriteOutcome(false)` → `OtOpcUaNodeManager` reverts the optimistic node value and raises the Part 8 audit event. So making the advise-failure path return Bad is sufficient to activate the entire existing self-correction surface for this failure class — no new plumbing.
+
+Test-seam constraint verified: `MxGatewaySession` (SDK package `ZB.MOM.WW.MxGateway.Client`) is sealed with internal ctors; the existing 8 `GatewayGalaxyDataWriterTests` never execute `WriteOneAsync`. The round-1 "fake session" test design cannot be implemented as written.
+
+### Root cause
+
+Two independent optimistic defaults: (1) empty `Statuses` treated as success although it only proves worker-side command acceptance; (2) the supervisory-advise precondition is best-effort — on failure the code still issues a Write it *knows* cannot commit and reports the (empty/OK) reply as Good.
+
+### Proposed design
+
+Three in-repo layers (fail-closed + metered), plus a tracked cross-repo follow-up:
+
+1. **Fail-closed on advise failure.** `EnsureSupervisoryAdvisedAsync` → `Task` (false on non-OK protocol status; the `TryRemove` + warning stay). In `WriteOneAsync`, when a non-secured write's advise returns false: **do not issue the raw Write**; increment `WriteAdviseFailed`; return `new WriteResult(StatusCodeMap.BadCommunicationError)`. Rationale for `BadCommunicationError` over `Uncertain`: it is already this writer's "couldn't reach the device" status (`:184`, `:290`), and only a Bad status triggers the #5 revert, killing the phantom-Good node. Advise failure is judged on protocol status only — matching current semantics; whether the worker emits MX status rows for `AdviseSupervisory` is an unverified gateway contract, and misreading it would fail-close healthy writes.
+2. **Meter the empty-statuses Good.** `TranslateReply` keeps returning `Good` on an empty statuses array (changing it to Uncertain needs the gateway to guarantee a status row on success — a contract question) but increments `WriteUnconfirmed` and the XML doc gains a "provisional pending WriteComplete correlation" note. This makes the unconfirmed-write *rate* operator-visible today.
+3. **Counters.** New static Meter + counters in the driver (meter name reuses the pump's so one host listener catches both):
+
+```csharp
+// GatewayGalaxyDataWriter.cs (or GalaxyTelemetry.cs — same assembly)
+private static readonly Meter WriterMeter = new(EventPump.MeterName); // "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"
+private static readonly Counter WriteAdviseFailed =
+    WriterMeter.CreateCounter("galaxy.writes.advise_failed", unit: "{write}",
+        description: "Writes short-circuited to Bad because AdviseSupervisory failed (value could not have committed).");
+private static readonly Counter WriteUnconfirmed =
+    WriterMeter.CreateCounter("galaxy.writes.unconfirmed", unit: "{write}",
+        description: "Writes reported Good off an EMPTY gateway statuses array (command accepted; commit unconfirmed).");
+```
+
+4. **Testability seam (replaces round-1's infeasible fake session).** Extract the per-write gateway operations behind an internal interface; production adapts the real session, tests inject a fake:
+
+```csharp
+internal interface IMxWriteOps
+{
+    string SessionId { get; }
+    int ServerHandle { get; }
+    Task AddItemAsync(string fullRef, CancellationToken ct);
+    Task InvokeAsync(MxCommandRequest request, CancellationToken ct);
+    Task WriteRawAsync(int itemHandle, MxValue value, int userId, CancellationToken ct);
+}
+```
+
+`WriteAsync` builds a `SessionMxWriteOps(session, serverHandle)` once per batch; `WriteOneAsync` / `EnsureItemHandleAsync` / `EnsureSupervisoryAdvisedAsync` / `InvokeWriteSecuredAsync` take `IMxWriteOps` instead of `(MxGatewaySession, int)`. Add `internal Task WriteOneForTestAsync(IMxWriteOps ops, WriteRequest request, SecurityClassification classification, CancellationToken ct)` so tests drive the real pipeline. Behavior-preserving refactor; the public `IGalaxyDataWriter` surface is untouched.
+
+**Cross-repo (mxaccessgw) follow-up — the real fix, out of this plan's scope:** gateway-side `WriteComplete` correlation so the unary reply's `Statuses` row carries the actual COM commit outcome (the `OnWriteComplete` event family already exists in the proto and is filtered out at `EventPump.cs:200-206`). File on the MxAccessGateway backlog; link from the in-source comment at `GatewayGalaxyDataWriter.cs:161-166`. Until then, layer 2's meter is the honest signal.
+
+**Alternatives considered:** *return `Uncertain` on advise failure* (report's option a) — rejected: Uncertain does not fire the #5 revert, leaving the phantom-Good node; Bad is strictly more truthful ("this write did not reach the device"). *Fail `TranslateReply` empty-statuses to Bad now* — rejected: would fail-close every Galaxy write if the gateway legitimately returns empty statuses on success (contract unverified); metered-Good is the safe interim. *Public seam / new abstraction project* — rejected: internal + `InternalsVisibleTo` (already in place for the existing internal test seams) is sufficient.
+
+### Implementation steps
+
+1. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs` — `IMxWriteOps` + `SessionMxWriteOps` adapter (same file, internal), thread through the four private methods, `EnsureSupervisoryAdvisedAsync` → `Task`, short-circuit branch, counters, `TranslateReply` meter + doc note, `WriteOneForTestAsync` seam.
+2. Update the comment block at `:161-166` to state the new fail-closed behavior + cross-repo link.
+3. `docs/drivers/` Galaxy notes (if the driver doc mentions write semantics) — one paragraph on fail-closed advise + the unconfirmed meter.
+
+### Tests
+
+New `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs` (fake `IMxWriteOps` records calls):
+
+- advise reply carries non-OK protocol status ⇒ result is `BadCommunicationError`, **`WriteRawAsync` was never invoked**, `galaxy.writes.advise_failed` incremented (MeterListener), supervised-handle cache does NOT retain the handle;
+- advise OK + write reply with empty statuses ⇒ `Good` returned AND `galaxy.writes.unconfirmed` incremented;
+- advise OK + write reply with non-OK protocol status ⇒ `BadCommunicationError` (regression);
+- advise OK + write reply with a Bad MX status row ⇒ mapped via `StatusCodeMap.FromMxStatus` (regression);
+- secured-write path (`SecuredWrite` classification) never calls advise (regression);
+- second write to the same handle after a *successful* advise skips the advise round-trip (cache regression, now assertable through the fake).
+
+Existing 8 cache-level tests stay green (`--filter "FullyQualifiedName~GatewayGalaxyDataWriterTests"`). Live gateway smoke: extend the skip-gated `GatewayGalaxyLiveReopenAndWriteTests` with an assertion that a normal live write still returns Good (guard against over-eager fail-closing); recipe unchanged:
+
+```bash
+KEY=$(docker exec otopcua-dev-central-1-1 printenv GALAXY_MXGW_API_KEY)
+MXGW_ENDPOINT=http://10.100.0.48:5120 GALAXY_MXGW_API_KEY="$KEY" \
+  dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests \
+  --filter FullyQualifiedName~GatewayGalaxyLiveReopenAndWrite
+```
+
+### Effort + Risk
+
+**Effort:** S-M (the seam refactor is the bulk; the behavior change is ~15 lines). **Risk:** low-medium — the only behavior change is advise-failed writes returning Bad instead of phantom-Good (strictly more correct, and it *enables* the #5 revert); the seam refactor is mechanical and covered by the new pipeline tests. Live-verify normal writes still Good before merging.
+
+---
+
+## Finding 3 — 03/S4: Primary gate defaults to allow while role unknown — dual-primary data-plane window (High)
+
+### Restatement
+
+`DriverHostActor` gates inbound operator writes (`:1039`), native alarm acks (`:1095`), and the native-alarm alerts publish (`:977`) on `_localRole is Secondary or Detached` — `null` (no snapshot yet) falls through to **allow**. A freshly-booted secondary on a multi-node cluster services shared-field-device writes and emits fleet-wide alerts as if it were Primary for up to the 10 s `RedundancyStateActor` heartbeat — and indefinitely if the snapshot's NodeId never matches this node (the 03/S5 identity-mismatch shape; `OnRedundancyStateChanged` at `:1126-1133` leaves the role unchanged when the snapshot omits this node). Compounds with at-most-once DPS delivery. This delivery path has real history: it was **double-broken once** (NodeId host:port mismatch + late-subscriber missing the heartbeat) and was invisible to unit tests — the fix must carry a delivery-level verification, not just TestKit guards.
+
+### Verification
+
+Confirmed exact (see summary table). Additional facts: DPS subscribe to `redundancy-state` in `PreStart` (`:385-393`); the boot-window rationale is written into the `_localRole` doc (`:193-201`): "default-allow while unknown so single-node deploys + the boot window never reject". Existing tests pin behavior: `DriverHostActorWriteRoutingTests` — Secondary write rejected with reason exactly `"not primary"` (`:76-102`), and a write **before any snapshot is serviced** (`:49-74`, single-node test cluster). The same default-allow gate exists in `ScriptedAlarmHostActor.cs:314` (alerts-emit for scripted alarms). `RuntimeActorTestBase` runs a real single-node cluster with **no roles**, so a default member-count of "Up members with role `driver`" evaluates to 0 there ⇒ the existing no-snapshot test keeps passing untouched.
+
+### Root cause
+
+The gate uses the *role* signal to gate *shared-device* writes, and the role signal is under-determined at boot: `null` cannot distinguish "single node — no snapshot will ever demote me" from "multi-node — my snapshot just hasn't arrived". The correct discriminator (cluster membership) is already available in-process and is authoritative much earlier than DPS delivery.
+
+### Proposed design
+
+**Cluster-membership-aware default-deny**, one policy in one place:
+
+```csharp
+// src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs  (new)
+/// Single decision point for the Primary data-plane gates (inbound device writes, native
+/// alarm acks, fleet-wide alerts publishes). Known role wins outright; an UNKNOWN role (no snapshot
+/// yet, or the snapshot never mentioned this node) is resolved by cluster membership: a single-driver
+/// cluster stays default-allow (no peer exists — the boot-window/single-node posture), a multi-driver
+/// cluster is default-DENY (a real Primary peer exists; do not touch the shared field device until
+/// the redundancy snapshot proves this node is it).
+public static class PrimaryGatePolicy
+{
+    public static bool ShouldServiceAsPrimary(RedundancyRole? localRole, int driverMemberCount) =>
+        localRole switch
+        {
+            RedundancyRole.Primary => true,
+            RedundancyRole.Secondary or RedundancyRole.Detached => false,
+            _ => driverMemberCount <= 1,   // unknown: allow only when no driver peer exists
+        };
+}
+```
+
+**`DriverHostActor` wiring:**
+
+- New optional Props/ctor parameter `Func? driverMemberCountProvider = null` (test seam, mirrors the existing override-parameter idiom). Default implementation:
+
+```csharp
+private int DriverMemberCount()
+{
+    if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
+    try
+    {
+        return Akka.Cluster.Cluster.Get(Context.System).State.Members
+            .Count(m => m.Status == MemberStatus.Up && m.Roles.Contains("driver"));
+    }
+    catch (Exception) { return 0; }   // non-cluster ActorRefProvider (legacy harnesses) ⇒ single-node posture
+}
+
+private bool ShouldServiceAsPrimary() => PrimaryGatePolicy.ShouldServiceAsPrimary(_localRole, DriverMemberCount());
+```
+
+- All three gate sites route through `ShouldServiceAsPrimary()`; reasons + visibility:
+  - `HandleRouteNodeWrite` (`:1035-1043`): on deny, reply `NodeWriteResult(false, _localRole is null ? "not primary (role unknown)" : "not primary")` — the existing exact-string test keeps passing, boot-window denials are distinguishable — and increment the denial meter (`site:"write"`, `reason:"role-unknown"|"secondary"|"detached"`). The rejection then rides the established surface: `ActorNodeWriteGateway` Warning log (`:60`) → `NodeWriteOutcome(false)` → node revert + Bad blip + `AuditWriteUpdateEvent`. **No queueing** (see resolved choice #6).
+  - `HandleRouteNativeAlarmAck` (`:1091-1100`): on deny, drop as today but elevate the role-unknown case from Debug to **Warning** (the operator's ack silently not reaching the upstream alarm system is precisely a swallowed failure) + meter (`site:"ack"`).
+  - `ForwardNativeAlarm` (`:955-1002`): compute `ShouldServiceAsPrimary()` **once before the nodeIds loop**; on deny `continue` as today + meter (`site:"alarm-emit"`, Debug log). The OPC UA condition write above the gate stays UNGATED (warm standby keeps its address space current — no local data loss; only the fleet-wide duplicate publish is suppressed).
+- **S5 hook (log-once):** in `OnRedundancyStateChanged`, when the snapshot does **not** mention this node and `DriverMemberCount() > 1`, log a one-time Warning naming `_localNode` and the snapshot's node ids (the silent-identity-mismatch diagnostic the S5 finding asks for); a `bool _warnedSnapshotMissingLocalNode` field debounces it.
+
+**New instrument (`OtOpcUaTelemetry`):**
+
+```csharp
+/// Inbound operations denied by the Primary data-plane gate.
+public static readonly Counter PrimaryGateDenied =
+    Meter.CreateCounter("otopcua.redundancy.primary_gate_denied", unit: "{denial}",
+        description: "Operations denied by the Primary gate (site=write|ack|alarm-emit, reason=secondary|detached|role-unknown).");
+```
+
+**`ScriptedAlarmHostActor` consistency extension:** the emit gate at `:314` adopts the same `PrimaryGatePolicy` + optional `driverMemberCountProvider` Props param + meter (`site:"alarm-emit"`). Same trade-off resolution as the native emit gate (resolved choice #8).
+
+**Grace-window semantics, explicitly:** during boot on a multi-node cluster (role unknown), inbound writes are **rejected** — the OPC UA client sees the sanctioned optimistic-write self-correction (node reverts to prior value with a transient Bad blip; a Part 8 audit event records the rejected write; the CLI/client's own write call still returned Good synchronously, exactly as any failed device write behaves since #5). Acks are dropped with a Warning. Alerts publishes are skipped (the Primary peer publishes the single fleet copy). The window closes on the first received snapshot (≤ ~10 s heartbeat; typically the 250 ms-debounced initial publish). Writes are **not** queued and **no** synchronous `BadNotWritable` is introduced — both rejected above.
+
+**Alternatives considered (round-1 set, re-affirmed):** (a) unconditional default-deny — breaks single-node deploys (no snapshot ever arrives to enable writes); rejected. (b) fixed boot grace-timer flipping to deny — fragile, wrongly denies on a healthy multi-node cluster with a slow snapshot, and wrongly allows during the timer on a fast dual-boot; rejected. (c) lease/fencing token — the architecturally complete answer but a redundancy-design change (report notes "no lease or fencing"); out of scope. (d) subscribing to `ClusterEvent.IMemberEvent` and caching the count — rejected in favor of reading `_cluster.State.Members` per gate call: the state read is cheap, always fresh, and avoids a new message-handling surface in an already-large actor.
+
+### Implementation steps
+
+1. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs` — new pure policy class.
+2. `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs` — `PrimaryGateDenied` counter.
+3. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — Props/ctor param, `DriverMemberCount()`, `ShouldServiceAsPrimary()`, rewire `:977` / `:1039` / `:1095`, reasons + meters, S5 log-once in `OnRedundancyStateChanged`, update the `_localRole` xmldoc (`:193-201`) and the three gate xmldocs (`:1017-1034`, `:1073-1090`, `:946-953`) to the new default-deny-on-multi-node semantics.
+4. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — same policy at `:314` + Props param.
+5. `docs/Redundancy.md` — document the gate semantics (single-node allow / multi-node deny-until-snapshot, what a client sees during the window).
+
+### Tests
+
+- **Pure:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs` — the full truth table (Primary/Secondary/Detached/null × count 0,1,2).
+- **Unit (TestKit), `DriverHostActorPrimaryGateTests.cs`** (new, alongside `DriverHostActorWriteRoutingTests`):
+  - role unknown + provider→2 ⇒ `RouteNodeWrite` replies `(false, "not primary (role unknown)")` and the recording driver saw **no** write;
+  - role unknown + provider→1 ⇒ serviced (boot-window/single-node preserved);
+  - Primary snapshot then write ⇒ serviced; Secondary snapshot ⇒ `(false, "not primary")` (existing tests already pin this — keep them untouched as the regression net);
+  - role unknown + provider→2 ⇒ `RouteNativeAlarmAck` dropped (probe on the child sees nothing) and Warning logged (`EventFilter`);
+  - role unknown + provider→2 ⇒ `ForwardNativeAlarm` publishes **no** `AlarmTransitionEvent` to the alerts topic (DPS probe) while the `AlarmStateUpdate` to the publish actor still flows (ungated condition write);
+  - meter assertions via `MeterListener` (`site`/`reason` tags).
+  - Existing `DriverHostActorWriteRoutingTests` all green unchanged (single-node test cluster ⇒ default provider counts 0 driver members ⇒ allow).
+- **ScriptedAlarm:** mirror emit-gate test with provider→2 in the ScriptedAlarmHostActor suite.
+- **Integration (in-process 2-node, delivery-path — the "unit tests can't catch delivery" guard):** new `PrimaryGateFailoverTests` (or extend `HardKillFailoverTests`' harness usage) in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests` on `TwoNodeClusterHarness`: after both nodes join and snapshots deliver, assert the secondary's `DriverHostActor` rejects `RouteNodeWrite` with `"not primary"` while the primary services it — proving the DPS-delivered snapshot (not a test-injected message) actually drives the gate end-to-end.
+- **Live 2-node rig gate (required before trusting the change — see delivery-history note):** docker-dev rig, both centrals rebuilt (`:9200` round-robins central-1/2 — rebuild BOTH):
+  1. `docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2`; confirm roles via ServiceLevel — Client.CLI `read` of `Server_ServiceLevel` on `opc.tcp://localhost:4840` vs `:4841` shows **240 / 100**.
+  2. `docker restart otopcua-dev-central-2-1`; **during its boot window** issue a Client.CLI `write` against `opc.tcp://localhost:4841` to a known writable equipment node (bind an `opc-writeop`/`multi-role` LDAP user; write reads-first per the #5 recipe). Expected: the write does **not** reach the device — central-2 log shows `Operator write to … rejected: not primary (role unknown)` (or `not primary` if the snapshot already landed) + the node reverts (Bad blip) — and `otopcua.redundancy.primary_gate_denied` increments.
+  3. After ~10 s (snapshot heartbeat), a write against `:4841` rejects with plain `not primary`, and the same write against `:4840` (primary) succeeds end-to-end.
+  4. Regression: single `/alerts` row per native/scripted alarm transition (no duplicates, no missing rows) with both nodes steady — re-run the alarm-transition recipe from the native-alarms verification.
+
+### Effort + Risk
+
+**Effort:** S/M code + M verification (the live rig gate is the long pole). **Risk:** **medium-high — this changes write-admission policy on multi-node clusters** (actor + data-plane behavior). A policy bug could deny legitimate Primary writes during a snapshot gap; mitigations: the deny branch requires a *real* driver peer (count ≥2), the single-node path is bit-identical to today, the known-role branches are unchanged, and the exact-string regression tests pin existing behavior. The delivery-path history mandates the in-process 2-node test AND the live rig gate before merge.
+
+---
+
+## Task breakdown
+
+Branch: one branch `fix/archreview-r2-04-failure-visibility` off master, commits grouped per finding (the three findings touch disjoint files; if implementing in parallel, worktree-isolate per the shared-tree/git-index gotcha). Ordering: T1-block (01/S-1) first (data-contract), T2-block (06/S-1) and T3-block (03/S4) parallel after — or fully parallel in worktrees.
+
+Verification bar: every task has a deterministic unit guard; T3 additionally has the wiring/delivery assertions (in-process 2-node) and the live 2-node rig gate. Follow TDD: write the failing test, see it FAIL, implement, see it PASS.
+
+---
+
+### T1 — Extend `AddressSpaceApplyOutcome` + `SafeRebuild` failure flag
+
+**Classification:** HIGH-RISK (public data-contract change on the deploy path)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T5–T13 (different files); NOT T2/T3/T4 (same files)
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs` (new)
+
+1. Write `AddressSpaceApplierFailureSurfaceTests.Apply_WhenRebuildThrows_ReportsRebuildFailed`: applier over a sink whose `RebuildAddressSpace` throws (extend `ThrowingSink` with `throwOnRebuild: true` or a local fixture), plan with one added equipment ⇒ assert `outcome.RebuildCalled == true && outcome.RebuildFailed == true`; and `Apply_HappyPath_ReportsNoFailure` ⇒ `RebuildFailed == false, FailedNodes == 0`.
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceApplierFailureSurfaceTests"` → **FAIL** (no such members).
+3. Implement: record gains `bool RebuildFailed = false, int FailedNodes = 0`; `SafeRebuild()` returns `bool`; thread `rebuildFailed` through the three sites (`:150-154`, `:191`, `:196-198`); main return passes both fields.
+4. Re-run filter → **PASS**; then the full existing suite: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceApplierTests"` → **PASS** (defaulted params keep old constructions compiling).
+5. Commit: `fix(applier): AddressSpaceApplyOutcome carries RebuildFailed/FailedNodes (archreview 01/S-1)`.
+
+### T2 — `Safe*` helpers return bool; removal-pass tally into `FailedNodes`
+
+**Classification:** high-risk (same contract surface)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T5–T13
+**Files:** same as T1
+
+1. Add test `Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes` (ThrowingSink `throwOnAlarmWrite: true`, plan with one removed alarm) ⇒ `outcome.FailedNodes == 1`. Run filter → **FAIL**.
+2. Implement: `SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition`/`SafeEnsureFolder`/`SafeEnsureVariable` → `bool`; `Apply` tallies removal-pass failures.
+3. Filter → **PASS**. Commit: `fix(applier): Safe* helpers report failure; Apply tallies removal-pass failures`.
+
+### T3 — `Materialise*` passes return failed-node counts
+
+**Classification:** medium-risk (public `void`→`int`, source-compatible)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T5–T13
+**Files:** same as T1
+
+1. Tests: `MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount` (ThrowingSink `throwOnEnsureVariable`, composition with 3 tags, sink throws on all ⇒ returns 3), plus happy-path-returns-0 for all five methods (Hierarchy, EquipmentTags, EquipmentVirtualTags, ScriptedAlarms, DiscoveredNodes). Run → **FAIL** (return type).
+2. Implement the five `int` returns (each pass counts its `Safe*` falses).
+3. Filter + full applier suites → **PASS**. Commit: `fix(applier): Materialise* passes return swallowed-failure tallies`.
+
+### T4 — `OpcUaApplyFailed` counter + publish-actor surfacing
+
+**Classification:** medium-risk (actor logging/metric branch; no message-flow change)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T5–T13 (Commons file also touched by T9 — coordinate or sequence)
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs` (new)
+
+1. Test (TestKit + MeterListener + EventFilter): rebuild through a throwing sink ⇒ one `otopcua.opcua.apply.failed` increment + an Error log containing `DEGRADED`; happy path ⇒ zero increments, Info log. Run `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~OpcUaPublishActorApplyFailureTests"` → **FAIL**.
+2. Implement: counter in `OtOpcUaTelemetry`; `HandleRebuild` sums `outcome.FailedNodes` + four materialise returns, branches Error+meter vs Info (code in Finding 1); `HandleMaterialiseDiscovered` same for its pass.
+3. Filter → **PASS**; run full `Runtime.Tests`. Commit: `fix(runtime): surface degraded address-space applies (Error log + otopcua.opcua.apply.failed)`.
+
+### T5 — Galaxy `IMxWriteOps` seam (behavior-preserving refactor)
+
+**Classification:** medium-risk (write-path refactor, no behavior change)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T4, T9–T13
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs` (new)
+
+1. Test-first through the seam: `WriteOne_SecuredClassification_RoutesThroughWriteSecured_NoAdvise` with a fake `IMxWriteOps` returning an OK reply ⇒ Good, `InvokeAsync` called once with `WriteSecured`, no `AdviseSupervisory`. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests --filter "FullyQualifiedName~GatewayGalaxyDataWriterFailClosedTests"` → **FAIL** (seam absent).
+2. Implement: `IMxWriteOps` + `SessionMxWriteOps` (internal, same file); thread through `WriteOneAsync`/`EnsureItemHandleAsync`/`EnsureSupervisoryAdvisedAsync`/`InvokeWriteSecuredAsync`; add `WriteOneForTestAsync`.
+3. Filter + existing `GatewayGalaxyDataWriterTests` → **PASS**. Commit: `refactor(galaxy): internal IMxWriteOps seam under GatewayGalaxyDataWriter (sealed SDK session untestable otherwise)`.
+
+### T6 — Galaxy fail-closed on advise failure
+
+**Classification:** medium-risk (behavior change: knowingly-lost writes now return Bad)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T4, T9–T13; after T5
+**Files:** same as T5
+
+1. Tests: `WriteOne_AdviseFails_ReturnsBadCommunicationError_AndNeverIssuesRawWrite` (fake ops: advise reply non-OK protocol status ⇒ assert status `StatusCodeMap.BadCommunicationError`, `WriteRawAsync` never called, handle absent from supervised cache); `WriteOne_AdviseSucceeds_RawWriteProceeds` (regression). Run filter → **FAIL**.
+2. Implement: `EnsureSupervisoryAdvisedAsync` → `Task`; short-circuit branch in `WriteOneAsync`; update the `:161-166` comment (fail-closed + mxaccessgw `WriteComplete` follow-up link).
+3. Filter → **PASS**. Commit: `fix(galaxy): fail-closed write when AdviseSupervisory fails — Bad status feeds #5 node revert (archreview 06/S-1)`.
+
+### T7 — Galaxy write counters (`advise_failed` / `unconfirmed`)
+
+**Classification:** standard
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T4, T9–T13; after T6
+**Files:** same as T5
+
+1. Tests (MeterListener on `ZB.MOM.WW.OtOpcUa.Driver.Galaxy`): advise-failure increments `galaxy.writes.advise_failed`; empty-statuses Good increments `galaxy.writes.unconfirmed`; a status-row reply increments neither. Run filter → **FAIL**.
+2. Implement counters (static `Meter` on `EventPump.MeterName`) + `TranslateReply` empty-statuses meter + XML "provisional" note.
+3. Filter → **PASS**. Commit: `feat(galaxy): meter unconfirmed + advise-failed writes`.
+
+### T8 — Galaxy live smoke guard (skip-gated)
+
+**Classification:** standard (test-only)
+**Estimated implement time:** 5 min
+**Parallelizable with:** everything except T6/T7 (same test project files)
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyLiveReopenAndWriteTests.cs`
+
+1. Add a live case asserting a normal advised write still returns Good post-change (guards against over-eager fail-closing). Skips cleanly without `MXGW_ENDPOINT`/`GALAXY_MXGW_API_KEY`.
+2. Run the skip path locally (`--filter FullyQualifiedName~GatewayGalaxyLiveReopenAndWrite` → skipped); the operator runs it live per the recipe in Finding 2. Commit: `test(galaxy): live guard — advised write remains Good under fail-closed advise`.
+
+### T9 — `PrimaryGatePolicy` + truth-table tests + denial counter
+
+**Classification:** standard (pure class + counter)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T8 (coordinate `OtOpcUaTelemetry.cs` with T4)
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs` (new), `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs` (new)
+
+1. Truth-table test (Theory: Primary⇒true any count; Secondary/Detached⇒false any count; null⇒true at 0/1, false at 2+). `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~PrimaryGatePolicyTests"` → **FAIL**.
+2. Implement policy + `PrimaryGateDenied` counter. Filter → **PASS**. Commit: `feat(runtime): PrimaryGatePolicy — membership-aware default-deny + denial meter (archreview 03/S4)`.
+
+### T10 — Wire the three `DriverHostActor` gates through the policy
+
+**Classification:** HIGH-RISK (actor write-admission policy change on the data plane)
+**Estimated implement time:** 5 min (edit) — verification is T11
+**Parallelizable with:** T1–T8; after T9
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
+
+1. Add `Func? driverMemberCountProvider` Props/ctor param, `DriverMemberCount()` (try/catch → 0), `ShouldServiceAsPrimary()`; rewire `:977`/`:1039`/`:1095` with reasons + meters per Finding 3; S5 log-once in `OnRedundancyStateChanged`; update the four xmldocs.
+2. Regression gate BEFORE new tests: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DriverHostActorWriteRoutingTests"` → **PASS unchanged** (single-node test cluster ⇒ count 0 ⇒ allow; exact `"not primary"` string preserved on Secondary).
+3. Commit: `fix(runtime): primary gates default-DENY on multi-driver clusters while role unknown (archreview 03/S4)`.
+
+### T11 — `DriverHostActorPrimaryGateTests` (TestKit, provider-injected)
+
+**Classification:** high-risk verification (guards T10)
+**Estimated implement time:** 5 min per case — split across commits if needed
+**Parallelizable with:** T1–T8; after T10
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs` (new)
+
+Cases (each FAIL→PASS against a temporarily-reverted gate is the negative control; run filter `FullyQualifiedName~DriverHostActorPrimaryGateTests`):
+1. role unknown + provider→2 ⇒ `RouteNodeWrite` → `(false, "not primary (role unknown)")`, recorder driver saw no write.
+2. role unknown + provider→1 ⇒ write serviced.
+3. Primary snapshot + provider→2 ⇒ serviced; Secondary ⇒ `(false, "not primary")`.
+4. role unknown + provider→2 ⇒ native ack dropped + Warning (EventFilter).
+5. role unknown + provider→2 ⇒ no alerts-topic publish from `ForwardNativeAlarm`, but `AlarmStateUpdate` still reaches the publish-actor probe (ungated condition write).
+6. MeterListener: denial counter tags (`site`,`reason`).
+Commit: `test(runtime): primary-gate boot-window guards (deny-on-unknown multi-node, allow single-node)`.
+
+### T12 — `ScriptedAlarmHostActor` emit-gate consistency
+
+**Classification:** medium-risk (actor gate change, emit-only)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T8; after T9
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs`, its test suite
+
+1. Test: role unknown + provider→2 ⇒ engine emission NOT published to alerts topic (DPS probe); provider→1 ⇒ published. Run its filter → **FAIL**.
+2. Implement: Props param + policy at `:314` + meter. Filter → **PASS**. Commit: `fix(runtime): scripted-alarm alerts emit uses PrimaryGatePolicy (consistency with 03/S4)`.
+
+### T13 — In-process 2-node delivery-path test
+
+**Classification:** high-risk verification (the "unit tests can't catch delivery" guard)
+**Estimated implement time:** ~15 min (harness plumbing) — the one deliberately-larger task
+**Parallelizable with:** T1–T8; after T10/T11
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs` (new, on `TwoNodeClusterHarness`)
+
+1. Two-node harness up, deployment applied, redundancy snapshots delivered (await ServiceLevel/role convergence as `HardKillFailoverTests` does); Ask `RouteNodeWrite` on the secondary's `DriverHostActor` ⇒ `(false, "not primary")`; same on the primary ⇒ serviced. This proves the REAL DPS-delivered snapshot drives the gate (not a test-injected message).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~PrimaryGateFailoverTests"` → **PASS** (and FAIL under a negative control that blocks the redundancy topic subscribe).
+3. Commit: `test(integration): 2-node delivery-path guard for the primary write gate`.
+
+### T14 — `docs/Redundancy.md` + CLAUDE.md-adjacent doc touch
+
+**Classification:** standard (docs)
+**Estimated implement time:** 5 min
+**Parallelizable with:** all
+**Files:** `docs/Redundancy.md`
+
+Document the gate policy table (role × cluster size), the boot-window client experience (optimistic-Good → revert + Bad blip + audit event), the new meters (`otopcua.redundancy.primary_gate_denied`, `otopcua.opcua.apply.failed`, `galaxy.writes.*`), and the fail-closed Galaxy write semantics. Commit: `docs: failure-visibility trio semantics (R2-04)`.
+
+### T15 — LIVE GATE: 2-node docker-dev rig verification (03/S4) — operator/rig task
+
+**Classification:** HIGH-RISK verification gate (merge-blocking for the S4 slice)
+**Estimated implement time:** ~30 min rig session
+**Parallelizable with:** nothing (needs the built branch)
+**Files:** none (rig session; findings recorded in STATUS.md)
+
+Run the four-step live recipe in Finding 3's Tests section (rebuild BOTH centrals; ServiceLevel 240/100 check; boot-window write against `:4841` rejected + reverted; post-convergence write against `:4840` succeeds; single `/alerts` row regression). Also eyeball one degraded-apply Error line is absent on a clean deploy (T4 sanity) and, if the Galaxy gw is reachable, run T8's live smoke. Record outcomes in `archreview/plans/STATUS.md`.
+
+---
+
+### Sequencing summary
+
+```
+T1 → T2 → T3 → T4          (01/S-1, sequential — same files)
+T5 → T6 → T7, T8           (06/S-1, sequential after seam; T8 anytime)
+T9 → T10 → T11 → T13       (03/S4 core)
+T9 → T12                   (03/S4 consistency)
+T14 anytime; T15 last (after T10–T13 merge-ready)
+```
+
+Overall effort: **~1.5–2 dev-days code+unit, plus the rig session**. The two HIGH-RISK slices (T1 record change, T10 gate change) each land behind their full regression suites before anything else stacks on them.
diff --git a/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json
new file mode 100644
index 00000000..1d922cbe
--- /dev/null
+++ b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json
@@ -0,0 +1,21 @@
+{
+  "planPath": "archreview/plans/R2-04-failure-visibility-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    { "id": "T1", "subject": "Extend AddressSpaceApplyOutcome with RebuildFailed/FailedNodes + SafeRebuild returns bool (01/S-1)", "status": "pending", "blockedBy": [] },
+    { "id": "T2", "subject": "Safe* helpers return bool; Apply tallies removal-pass failures into FailedNodes", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T3", "subject": "Materialise* passes return swallowed-failure counts (void -> int, five methods)", "status": "pending", "blockedBy": ["T2"] },
+    { "id": "T4", "subject": "OpcUaApplyFailed counter + OpcUaPublishActor Error/meter surfacing of degraded applies", "status": "pending", "blockedBy": ["T3"] },
+    { "id": "T5", "subject": "Galaxy internal IMxWriteOps seam under GatewayGalaxyDataWriter (sealed SDK session untestable) (06/S-1)", "status": "pending", "blockedBy": [] },
+    { "id": "T6", "subject": "Galaxy fail-closed write on AdviseSupervisory failure -> BadCommunicationError, no raw write", "status": "pending", "blockedBy": ["T5"] },
+    { "id": "T7", "subject": "Galaxy counters galaxy.writes.advise_failed + galaxy.writes.unconfirmed (empty-statuses Good metered)", "status": "pending", "blockedBy": ["T6"] },
+    { "id": "T8", "subject": "Galaxy skip-gated live smoke: normal advised write still returns Good post-change", "status": "pending", "blockedBy": ["T6"] },
+    { "id": "T9", "subject": "PrimaryGatePolicy pure class + truth-table tests + otopcua.redundancy.primary_gate_denied counter (03/S4)", "status": "pending", "blockedBy": [] },
+    { "id": "T10", "subject": "Wire DriverHostActor gates (:977/:1039/:1095) through PrimaryGatePolicy with member-count provider + S5 log-once", "status": "pending", "blockedBy": ["T9"] },
+    { "id": "T11", "subject": "DriverHostActorPrimaryGateTests: deny-on-unknown multi-node, allow single-node, ack/emit variants, meter tags", "status": "pending", "blockedBy": ["T10"] },
+    { "id": "T12", "subject": "ScriptedAlarmHostActor alerts-emit gate adopts PrimaryGatePolicy (consistency extension)", "status": "pending", "blockedBy": ["T9"] },
+    { "id": "T13", "subject": "In-process 2-node delivery-path test (TwoNodeClusterHarness): DPS-delivered snapshot drives the gate", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T14", "subject": "docs/Redundancy.md: gate policy table, boot-window client experience, new meters, Galaxy fail-closed semantics", "status": "pending", "blockedBy": [] },
+    { "id": "T15", "subject": "LIVE GATE: 2-node docker-dev rig verify (ServiceLevel 240/100, boot-window write rejected+reverted, single /alerts row)", "status": "pending", "blockedBy": ["T10", "T11", "T12", "T13"] }
+  ]
+}
diff --git a/archreview/plans/R2-05-adminui-authz-plan.md b/archreview/plans/R2-05-adminui-authz-plan.md
new file mode 100644
index 00000000..22cc3dce
--- /dev/null
+++ b/archreview/plans/R2-05-adminui-authz-plan.md
@@ -0,0 +1,575 @@
+# R2-05 — AdminUI Authorization Gating: Design + Implementation Plan
+
+> **Status (2026-07-12):** not started. This is round-2 remediation item **R2-05** — action item **#5** in
+> [`archreview/00-OVERALL.md`](../00-OVERALL.md) ("Gate AdminUI mutating pages with real policies … Impact: High"),
+> carried **High** from round 1 (04/C-1, unscheduled in the first remediation wave). Supersedes/extends §1 (C-1)
+> of the round-1 [`04-adminui-plan.md`](04-adminui-plan.md) — that section was re-verified at `f6eaa267` and its
+> `ConfigEditor`-policy + reflection-guard design is retained, refreshed with two verification corrections
+> (Reservations and ClusterRedundancy are read-only; Home carries no attribute at all) and extended to a
+> single-idiom end state enforceable by the guard.
+
+- **Source report:** `archreview/04-adminui.md` §C-1 (re-review 2026-07-12; **STILL OPEN**, stakes rose — the
+  ungated driver pages now author production-live retry/breaker `ResilienceConfig` since #10 `bacea1a4` /
+  #13 `75403caa` merged)
+- **Verified against tree at:** master `f6eaa267` (clean; zero AdminUI source changes since the `9cad9ed0` round-1 baseline)
+- **Scope:** page-level `@attribute [Authorize…]` across `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/`,
+  policy definitions in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`, the imperative /
+  `AuthorizeView` / endpoint-group literals that reference policies, and the new guard tests.
+- **Out of scope:** OPC UA data-plane authorization (independent LDAP path), the SignalR hubs (all `[Authorize]`,
+  read-only feeds for out-of-process clients — unchanged), `DeployApiEndpoints` (`AllowAnonymous` + self-gated
+  `X-Api-Key`, already correct and test-pinned), per-cluster permissions (**maintainer posture: roles are global
+  by design; simplest authz that works**).
+
+---
+
+## Verification summary
+
+Re-verified 2026-07-12 by exhaustive enumeration (`grep -rn "\[Authorize" --include="*.razor"` + `grep -rn "@page"`
+over `Components/Pages/`, plus every imperative `AuthorizeAsync`/`AuthorizeView`/`RequireAuthorization` site).
+**Finding CONFIRMED at `f6eaa267`, with two corrections and one addition** (flagged ⚠ below).
+
+**Registered policies today** (`Security/ServiceCollectionExtensions.cs:143-161`): `FallbackPolicy` =
+`RequireAuthenticatedUser` (cookie scheme); `"DriverOperator"` = `RequireRole("Operator","Administrator")` (:155);
+`"FleetAdmin"` = `RequireRole("Administrator")` (:160). All names are string literals — **no constants type exists**.
+Roles: `AdminRole` enum = `Viewer` / `Designer` / `Administrator` (`Configuration/Enums/AdminRole.cs`); `Operator`
+is appsettings-only (`Security/Auth/DevAuthRoles.cs:15`). No write-gate policy exists.
+
+### Exhaustive routable-page census (38 components, every `@page` in the AdminUI assembly)
+
+| # | Page (file, attribute line) | Route(s) | Current attribute | Mutates? |
+|---|---|---|---|---|
+| 1 | `Uns/GlobalUns.razor:2` | `/uns` | bare `[Authorize]` | **Yes** — Area/Line/Equipment CRUD, CSV import |
+| 2 | `Uns/EquipmentPage.razor:7` | `/uns/equipment/new`, `/uns/equipment/{EquipmentId}` | bare `[Authorize]` | **Yes** — equipment + tag/vtag/alarm CRUD |
+| 3 | `Clusters/NewCluster.razor:2` | `/clusters/new` | bare `[Authorize]` | **Yes** |
+| 4 | `Clusters/ClusterEdit.razor:4` | `/clusters/{ClusterId}/edit` | bare `[Authorize]` | **Yes** |
+| 5 | `Clusters/NodeEdit.razor:5` | `/clusters/{ClusterId}/nodes/new`, `…/nodes/{NodeId}` | bare `[Authorize]` | **Yes** |
+| 6 | `Clusters/NamespaceEdit.razor:6` | `/clusters/{ClusterId}/namespaces/new`, `…/{NamespaceId}` | bare `[Authorize]` | **Yes** |
+| 7 | `Clusters/AclEdit.razor:3` | `/clusters/{ClusterId}/acls/new`, `…/{NodeAclId}` | bare `[Authorize]` | **Yes** — ACL grants |
+| 8 | `Clusters/Drivers/DriverTypePicker.razor:4` | `/clusters/{ClusterId}/drivers/new` | bare `[Authorize]` | Gateway to create flow |
+| 9 | `Clusters/Drivers/DriverEditRouter.razor:5` | `/clusters/{ClusterId}/drivers/{DriverInstanceId}` | bare `[Authorize]` | Dispatches to typed editors |
+| 10–17 | `Clusters/Drivers/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor:2` (each) | `/clusters/{ClusterId}/drivers/new/` | bare `[Authorize]` ×8 | **Yes** — driver config **incl. live `ResilienceConfig`** (`DriverResilienceSection` embedded in all 8) |
+| 18 | `Deployments.razor:12` | `/deployments` | `[Authorize(Roles = "Administrator,Designer")]` (short form; in-file `@using`) | **Yes** — deploy trigger |
+| 19 | `Scripts.razor:2` | `/scripts` | `[…Authorize(Roles = "Administrator,Designer")]` | **Yes** — script list + delete |
+| 20 | `ScriptEdit.razor:5` | `/scripts/new`, `/scripts/{ScriptId}` | `[…Authorize(Roles = "Administrator,Designer")]` | **Yes** — script CRUD |
+| 21 | `RoleGrants.razor:2` | `/role-grants` | `[…Authorize(Policy = "FleetAdmin")]` | **Yes** — Admin-only, keep |
+| 22 | `Home.razor` | `/` | ⚠ **NO attribute at all** (protected only by the endpoint FallbackPolicy; `AuthorizeRouteView` sees no requirement) | No |
+| 23 | `Fleet.razor:6` | `/fleet` | bare `[Authorize]` | No |
+| 24 | `Hosts.razor:7` | `/hosts` | bare `[Authorize]` | No (driver ops gated imperatively in `DriverStatusPanel`) |
+| 25 | `Alerts.razor:5` | `/alerts` | bare `[Authorize]` | View only (Ack/Shelve imperatively `DriverOperator`-gated at :165) |
+| 26 | `ScriptLog.razor:4` | `/script-log` | bare `[Authorize]` | No |
+| 27 | `AlarmsHistorian.razor:4` | `/alarms-historian` | bare `[Authorize]` | No |
+| 28 | `Account.razor:6` | `/account` | bare `[Authorize]` | No |
+| 29 | `Certificates.razor:2` | `/certificates` | bare `[Authorize]` page-level; mutating actions already `FleetAdmin` (`AuthorizeView` :90, imperative :186) | View at page level |
+| 30 | `Reservations.razor:2` | `/reservations` | bare `[Authorize]` | ⚠ **No — CORRECTION**: the page is a read-only `AsNoTracking` list (`Reservations.razor:63-69`); no mutation handler exists. The report's C-1 table lumps it into the mutating set — reclassified read-only here. |
+| 31 | `Clusters/ClustersList.razor:2` | `/clusters` | bare `[Authorize]` | No — nav links only |
+| 32 | `Clusters/ClusterOverview.razor:2` | `/clusters/{ClusterId}` | bare `[Authorize]` | No — links to gated editors |
+| 33 | `Clusters/ClusterAudit.razor:2` | `/clusters/{ClusterId}/audit` | bare `[Authorize]` | No |
+| 34 | `Clusters/ClusterAcls.razor:2` | `/clusters/{ClusterId}/acls` | bare `[Authorize]` | No — New/Edit are `href` links to `AclEdit` (verified: no in-place mutation) |
+| 35 | `Clusters/ClusterNamespaces.razor:2` | `/clusters/{ClusterId}/namespaces` | bare `[Authorize]` | No — links to `NamespaceEdit` |
+| 36 | `Clusters/ClusterDrivers.razor:2` | `/clusters/{ClusterId}/drivers` | bare `[Authorize]` | No — links to driver pages |
+| 37 | `Clusters/ClusterRedundancy.razor:2` | `/clusters/{ClusterId}/redundancy` | bare `[Authorize]` | ⚠ **No — CORRECTION**: pure read-only view of static redundancy config (`ClusterRedundancy.razor:94-107`, `AsNoTracking`, no form). Round-1 plan had slated it for `ConfigEditor`; reclassified read-only. |
+| 38 | `Login.razor:12` | `/login` | `[…AllowAnonymous]` | — (must stay anonymous; Admin-001) |
+
+### Non-page authorization surfaces (idiom census)
+
+| Site | Current | Disposition |
+|---|---|---|
+| `ScriptAnalysis/ScriptAnalysisEndpoints.cs:18` | `RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" })` | converge → `ConfigEditor` policy |
+| `Alerts.razor:165`, `Shared/Drivers/DriverStatusPanel.razor:166`, `Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor:124`, `Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor:75` | imperative `AuthorizeAsync(…, "DriverOperator")` string literal ×4 | literal → constant (behavior unchanged) |
+| `Certificates.razor:90` (``), `:186` (imperative `"FleetAdmin"`) | string literals | literal → constant (behavior unchanged) |
+| `Security/ServiceCollectionExtensions.cs:155,160` | `AddPolicy("DriverOperator"…)`, `AddPolicy("FleetAdmin"…)` literals | literal → constant; **add `ConfigEditor` + `AuthenticatedRead`** |
+| Hubs (`AlertHub`/`DriverStatusHub`/`FleetStatusHub`/`ScriptLogHub`) `[Authorize]` | read-only feeds | unchanged (out of scope) |
+| `Api/DeployApiEndpoints.cs` | `AllowAnonymous` + fixed-time `X-Api-Key` | unchanged (correct, test-pinned) |
+
+**Round-1 plan cross-check:** the `ConfigEditor` policy + reflection-guard proposal in `04-adminui-plan.md` §1 is
+sound and retained. Refreshed here: (a) Reservations + ClusterRedundancy move to the read tier (code-verified
+above); (b) the four "mixed list pages" need **no** `AuthorizeView` wrapping — they contain only `href` links to
+pages that are themselves gated (round 1's option A is unnecessary; a Viewer clicking "New driver" lands on the
+`NotAuthorized` slot from `Routes.razor:11-22` — acceptable, and hiding the links is deferred UX polish);
+(c) extended to a **single-idiom end state**: every routable page carries an explicit named-policy attribute, so
+the guard can enforce "no bare `[Authorize]` anywhere" instead of maintaining an allowed-bare list.
+
+---
+
+## R2-05 (High, 04/C-1) — One authz idiom; gate the mutating surface
+
+### Restatement
+
+Three authorization idioms coexist (bare attribute / role-string literals / named policy), and the largest
+mutating surface — `/uns`, equipment, every cluster/node/namespace/ACL editor, all 8 driver pages (which since
+#10/#13 author **production-live retry/breaker `ResilienceConfig`**) — carries only bare `[Authorize]`, i.e. the
+`RequireAuthenticatedUser` fallback. Any authenticated user, including a read-only **Viewer**, can create, edit,
+and delete UNS structure, tags, scripts-adjacent config, driver instances, ACL grants, and live resilience
+overrides. Only Deployments/Scripts/ScriptEdit (role strings) and RoleGrants (policy) are gated.
+
+### Verification
+
+Confirmed at `f6eaa267` — see the census above. Nothing is stale; two classification corrections (Reservations,
+ClusterRedundancy read-only) and one addition (Home has **no** attribute) were found and are folded into the design.
+
+### Root cause
+
+Pages were authored by copying the nearest neighbor, and the fused Host's `FallbackPolicy` makes bare
+`[Authorize]` *look* protective (login is required) while providing zero role separation. No policy-name constants
+exist, so the correct gate is undiscoverable and drift is the path of least resistance; nothing in CI observes
+which attribute a page carries (no bUnit; razor `@code`/attributes are live-only), so the gap survived every
+review until an attribute-metadata census was run.
+
+### Proposed design
+
+**Standardize on ONE idiom — named policies referenced via constants — and make the guard enforce it.**
+
+#### Alternatives considered
+
+| Option | Description | Verdict |
+|---|---|---|
+| (a) Per-page `Roles="…"` strings | Stamp `Roles = "Administrator,Designer"` on the mutating pages (extend the Deployments idiom) | **Rejected.** Role strings are brittle — the ConfigEditor→Designer / FleetAdmin→Administrator canonical rename already proved the vocabulary moves; every page would encode it. Leaves three idioms in place (RoleGrants keeps policy, imperative sites keep policy names). No compile-time anchor, so the guard would string-match. |
+| (b) **Named policies + constants** (recommended) | One `AdminUiPolicies` constants type; a policy per capability tier; every page declares `[Authorize(Policy = AdminUiPolicies.X)]`; imperative/`AuthorizeView`/endpoint sites use the same constants | **Chosen.** Collapses all three idioms into one; the role vocabulary lives in exactly one `AddPolicy` block; constants make gates discoverable and give the reflection guard a compile-time-safe expected map. Matches maintainer posture: global roles, simplest thing that works. |
+| (c) Authorize-by-folder convention | Guard infers the required policy from the page's folder (`Pages/Uns/**`, `Pages/Clusters/Drivers/**` → ConfigEditor; `Pages/**` else → read) with no per-page attribute changes beyond the mutating set | **Rejected.** Blazor has no folder-level attribute mechanism, so this is a pure test-side convention: implicit, and a file move silently changes a security gate. Also can't express the real split (read-only `ClusterRedundancy` lives beside editors in `Clusters/`). Explicit per-page attributes + an exhaustive map are the same cost and self-documenting. |
+| (d) Tighten the `FallbackPolicy` to `ConfigEditor` | One-line change | **Rejected.** Locks Viewers out of every dashboard; wrong tier for read pages; hides the per-page intent. |
+
+#### Policy set and role mapping
+
+New file `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs` (beside `DevAuthRoles.cs`, namespace
+`ZB.MOM.WW.OtOpcUa.Security.Auth`) — **string values of the two existing policies are kept identical to today's
+literals** so nothing must change atomically:
+
+```csharp
+/// 
+///     Names of the AdminUI authorization policies registered by AddOtOpcUaAuth.
+///     Single source of truth — pages, AuthorizeView blocks, imperative checks, and endpoint
+///     groups must reference these constants, never string literals (guarded by
+///     PageAuthorizationGuardTests). Policy → role mapping lives in ServiceCollectionExtensions.
+/// 
+public static class AdminUiPolicies
+{
+    /// Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator.
+    public const string FleetAdmin = "FleetAdmin";
+
+    /// Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator.
+    public const string DriverOperator = "DriverOperator";
+
+    /// Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer.
+    public const string ConfigEditor = "ConfigEditor";
+
+    /// Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user.
+    public const string AuthenticatedRead = "AuthenticatedRead";
+}
+```
+
+Registration in `AddOtOpcUaAuth` (`ServiceCollectionExtensions.cs:143-161`) — switch the two existing `AddPolicy`
+names to the constants and add:
+
+```csharp
+// ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live
+// ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the
+// Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer".
+o.AddPolicy(AdminUiPolicies.ConfigEditor, policy =>
+    policy.RequireRole("Administrator", "Designer"));
+
+// AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry
+// a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright.
+// Semantically identical to the FallbackPolicy.
+o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy =>
+    policy.RequireAuthenticatedUser());
+```
+
+| Policy | Satisfied by roles | Gates |
+|---|---|---|
+| `FleetAdmin` | `Administrator` | `RoleGrants` page; `Certificates` mutating actions (`AuthorizeView` + imperative — already so) |
+| `ConfigEditor` **(new)** | `Administrator`, `Designer` | the 20 config pages (below) + `/api/script-analysis/*` |
+| `DriverOperator` | `Operator`, `Administrator` | imperative sites only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, both live address pickers) — unchanged |
+| `AuthenticatedRead` **(new)** | any authenticated (`Viewer`, `Designer`, `Administrator`, `Operator`, role-less) | the 16 read-only pages |
+| *(none — `[AllowAnonymous]`)* | — | `Login` only |
+
+The dev rig is unaffected: `AutoLoginAuthenticationHandler` grants `DevAuthRoles.All` = every `AdminRole` +
+`Operator` (`DevAuthRoles.cs:18`), which satisfies all four policies.
+
+#### Page classification (target state — the guard's expected map)
+
+- **ConfigEditor (20):** `GlobalUns`, `EquipmentPage`, `NewCluster`, `ClusterEdit`, `NodeEdit`, `NamespaceEdit`,
+  `AclEdit`, `DriverTypePicker`, `DriverEditRouter`, `ModbusDriverPage`, `S7DriverPage`, `AbCipDriverPage`,
+  `AbLegacyDriverPage`, `TwinCATDriverPage`, `FocasDriverPage`, `OpcUaClientDriverPage`, `GalaxyDriverPage`,
+  `Deployments`, `Scripts`, `ScriptEdit`.
+- **FleetAdmin (1):** `RoleGrants`.
+- **AuthenticatedRead (16):** `Home`, `Fleet`, `Hosts`, `Alerts`, `ScriptLog`, `AlarmsHistorian`, `Account`,
+  `Certificates`, `Reservations`, `ClustersList`, `ClusterOverview`, `ClusterAudit`, `ClusterAcls`,
+  `ClusterNamespaces`, `ClusterDrivers`, `ClusterRedundancy`.
+- **AllowAnonymous (1):** `Login` (must stay — Admin-001).
+
+The `DriverTypePicker`/`DriverEditRouter` gateways and the 8 typed pages are all gated so a Viewer can't reach a
+driver form by any route. The four list pages stay viewable (their New/Edit `href` targets are gated; hiding the
+links behind `` is **deferred UX polish**, not security).
+
+### Implementation steps
+
+1. **Constants:** add `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs` (code above).
+2. **Registration:** in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` switch
+   `AddPolicy("DriverOperator"…)`/`AddPolicy("FleetAdmin"…)` (:155/:160) to the constants; add the `ConfigEditor`
+   and `AuthenticatedRead` registrations.
+3. **Imports:** append to `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor`:
+   ```razor
+   @using Microsoft.AspNetCore.Authorization
+   @using ZB.MOM.WW.OtOpcUa.Security.Auth
+   ```
+   Then **remove the now-duplicate in-file `@using Microsoft.AspNetCore.Authorization`** from
+   `Deployments.razor:2` and `Alerts.razor:7` (avoids CS0105 under `TreatWarningsAsErrors`).
+4. **Page attribute edits** — replace the existing `@attribute` line (cited in the census) in every file with the
+   exact line below; for `Home.razor` **insert** as line 2 (after `@page "/"`):
+
+   | File (under `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/`) | Line | New attribute line |
+   |---|---|---|
+   | `Uns/GlobalUns.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Uns/EquipmentPage.razor` | 7 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/NewCluster.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/ClusterEdit.razor` | 4 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/NodeEdit.razor` | 5 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/NamespaceEdit.razor` | 6 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/AclEdit.razor` | 3 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/DriverTypePicker.razor` | 4 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/DriverEditRouter.razor` | 5 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/ModbusDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/S7DriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/AbCipDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/AbLegacyDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/TwinCATDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/FocasDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/OpcUaClientDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Clusters/Drivers/GalaxyDriverPage.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Deployments.razor` | 12 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `Scripts.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `ScriptEdit.razor` | 5 | `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` |
+   | `RoleGrants.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]` |
+   | `Home.razor` | **insert 2** | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Fleet.razor` | 6 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Hosts.razor` | 7 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Alerts.razor` | 5 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `ScriptLog.razor` | 4 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `AlarmsHistorian.razor` | 4 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Account.razor` | 6 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Certificates.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Reservations.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClustersList.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterOverview.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterAudit.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterAcls.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterNamespaces.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterDrivers.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Clusters/ClusterRedundancy.razor` | 2 | `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]` |
+   | `Login.razor` | 12 | unchanged (`[…AllowAnonymous]`) |
+5. **Literal convergence (non-page sites):**
+   - `ScriptAnalysis/ScriptAnalysisEndpoints.cs:18` → `.RequireAuthorization(AdminUiPolicies.ConfigEditor)`
+     (drop the `new AuthorizeAttribute { Roles = … }`; add `using ZB.MOM.WW.OtOpcUa.Security.Auth;`; update the
+     method's comment + xmldoc to say "ConfigEditor policy (Administrator or Designer)"). Behavior identical.
+   - Imperative `"DriverOperator"` → `AdminUiPolicies.DriverOperator` in `Alerts.razor:165`,
+     `Components/Shared/Drivers/DriverStatusPanel.razor:166`,
+     `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor:124`,
+     `Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor:75`.
+   - `Certificates.razor:90` → ``; `:186` imperative literal →
+     `AdminUiPolicies.FleetAdmin`.
+6. **Docs:** update `CLAUDE.md` (ScriptAnalysis sentence: "gated to the `Administrator,Designer` roles" → "gated
+   by the `ConfigEditor` policy (Administrator or Designer)") and `docs/security.md` if it states AdminUI page
+   gating (grep for `FleetAdmin`/`Authorize` there) — the C-2 doc-drift lesson: change docs in the same PR as the gate.
+7. **STATUS/report bookkeeping:** add the R2-05 row to `archreview/plans/STATUS.md` and flip 04/C-1 in
+   `archreview/04-adminui.md`'s prior-finding table when merged.
+
+### Tests
+
+**No bUnit exists (documented repo posture), and the docker-dev rig auto-authenticates with ALL roles
+(`DisableLogin=true` → `DevAuthRoles.All`), so a Viewer-denied path can NEVER be observed on the default rig.
+The negative proof is therefore CI-side: the reflection guard + the policy-semantics unit test.** Live `/run` is
+the positive/regression pass only.
+
+1. **Reflection page-authorization guard** (the no-bUnit substitute; the single highest-leverage test here) —
+   new `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs`. Works purely
+   off component **metadata** (razor `@attribute`/`@page` compile to class-level attributes), needs no rendering:
+   - Enumerate `typeof(Routes).Assembly.GetTypes()` where the type carries at least one
+     `Microsoft.AspNetCore.Components.RouteAttribute` — **the same assembly anchor `Routes.razor` gives the
+     ``**, so the guard's universe is exactly the router's universe.
+   - **Rule 1 (no bare gates):** every routable type must carry `[AllowAnonymous]` or an `AuthorizeAttribute`
+     with a non-empty `Policy` and a null `Roles` — bans bare `[Authorize]`, the `Roles="…"` idiom, and
+     attribute-less pages (catches today's `Home`). Failure message lists every offender with its route template.
+   - **Rule 2 (exhaustive classification):** a compile-time `Dictionary` expected map (the
+     38-page table above, `typeof(GlobalUns)` → `AdminUiPolicies.ConfigEditor`, …) plus an `[AllowAnonymous]`
+     allowlist = `{ typeof(Login) }`. Assert (a) every routable type appears in exactly one of the two sets and
+     (b) its actual attribute matches. **A brand-new page fails the test until the author consciously classifies
+     it** — the anti-drift property, and this repo's "assert production wiring" house pattern (cf. the
+     deferred-sink forwarding guard).
+   - **Rule 3 (known policy names):** every `Policy` value must be one of the four `AdminUiPolicies` constants.
+   - TDD: **write this test FIRST against the target map — it must FAIL at `f6eaa267` listing the ~35 offending
+     pages** (34 bare + `Home` attribute-less; only `RoleGrants` passes Rule 1 as-is, and `Deployments`/`Scripts`/
+     `ScriptEdit` fail on the Roles idiom). Green only after every page edit lands.
+   - Note: if `AuthorizeAttribute`/`RouteAttribute` don't resolve in the test project, add
+     `` to
+     `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (the AdminUI RCL
+     reference normally flows it transitively).
+2. **Policy-registration/semantics test** — new
+   `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`, mirroring the existing
+   `AddOtOpcUaAuthWiringTests` construction (in-memory config with `Security:Auth:DisableLogin=false`,
+   `services.AddLogging(); services.AddOtOpcUaAuth(config);`, build provider). Resolve `IAuthorizationService`
+   and evaluate each policy against `ClaimsPrincipal`s built from role claims
+   (`new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), authenticationType: "Test")`;
+   an identity with no authenticationType = unauthenticated):
+   - `ConfigEditor`: `Viewer` **denied**; `Operator` **denied**; `Designer` allowed; `Administrator` allowed;
+     unauthenticated denied.
+   - `AuthenticatedRead`: any authenticated (incl. role-less) allowed; unauthenticated denied.
+   - `FleetAdmin`: only `Administrator`. `DriverOperator`: `Operator` + `Administrator` only. (Pins the two
+     pre-existing policies for the first time.)
+   - Dev-rig protection: `DevAuthRoles.All` satisfies all four policies (guards the auto-login principal against
+     a future policy requiring a role outside the grant).
+3. **Existing suites** must stay green — notably the 8 `*FormSerializationTests` (driver pages), `Uns` service
+   tests, and `ScriptAnalysis` suites (service-level; the endpoint gate change is metadata-only).
+4. **Live `/run` verification pass (docker-dev, positive/regression only — REQUIRED):** rebuild **both**
+   central-1 and central-2 images (`:9200` round-robins), bring the local `docker-dev` stack up, then with the
+   auto-admin session drive: `/` → `/uns` (create+delete a scratch area) → `/uns/equipment/{id}` (open Tags tab) →
+   `/clusters` → a driver page (open the resilience section) → `/deployments` (trigger a deploy) → `/role-grants` →
+   `/alerts`. Everything must load and mutate exactly as before — auto-login carries all roles, so **any** page
+   that breaks under the new attributes is a real over-gating regression (e.g. a typo'd policy name renders the
+   permission-denied slot even for the admin — this is precisely what the pass catches, and why it cannot be
+   skipped). **Do not treat this pass as negative-authz evidence** — disabled login auto-authenticates as a
+   full-access admin; the deny paths are proven by tests 1+2. *Optional stretch (real deny observation):* set
+   `Security__Auth__DisableLogin=false` on one AdminUI container against the shared GLAuth (`10.100.0.35:3893`),
+   bind a Viewer-mapped user, and confirm `/uns` renders the `NotAuthorized` slot while `/clusters` still lists.
+
+### Effort + Risk
+
+**Effort: Small-Medium** (~2.5–4 h): 2 new small files, 1 policy-registration edit, 37 one-line page edits,
+7 literal conversions, 2 test files, docs touch, live pass. **Risk: Medium** — wide but shallow blast radius
+(every AdminUI page's gate changes); each change is one declarative line; no data-plane impact (OPC UA auth is a
+separate LDAP path); the guard + live pass mitigate the two real failure modes (over-gating a read page /
+under-gating a mutation page).
+
+**Locked-out-user analysis (what each existing role sees before → after):**
+
+| Role | Before (f6eaa267) | After R2-05 | Change |
+|---|---|---|---|
+| `Viewer` | every page **including all config mutation** except Deployments/Scripts/ScriptEdit/RoleGrants | the 16 read pages only; denied on all 20 ConfigEditor pages + RoleGrants | **Intended fix** — this is the finding. Viewer keeps all dashboards/lists/live tails. |
+| `Designer` | everything except RoleGrants + Certificates mutating actions | identical | No change (ConfigEditor = Administrator+Designer, same set the role-string pages used). |
+| `Operator` (appsettings-only) | every page including config mutation (bare-`[Authorize]` hole) + DriverOperator actions | read pages + DriverOperator imperative actions; **newly denied on config pages** | **Deliberate narrowing** — Operator is the live-ops role, not a config author. ⚠ Deployment check: if any real LDAP deployment maps a config-authoring group to `Operator` via `GroupToRole`/RoleGrants, remap it to `Designer` before rollout. |
+| `Administrator` | everything | everything | No change. |
+| Auto-login dev principal | all roles | all roles (`DevAuthRoles.All` ⊇ Administrator+Designer+Operator) | No dev-rig regression (pinned by test 2). |
+| Authenticated, no mapped role | every page incl. mutation (!) | read pages only | Also narrowed, correctly. |
+
+---
+
+## Task breakdown
+
+Branch: `fix/archreview-r2-05-adminui-authz` off `master f6eaa267`. Build gate per commit:
+`dotnet build ZB.MOM.WW.OtOpcUa.slnx` green. Conventional commits as noted. Tasks 6–11 (page edits) are pure
+one-line-per-file mechanical edits and are **highly parallelizable** (disjoint files) — if parallel implementers
+are used, worktree-isolate (shared-tree git-index race, see memory).
+
+### Task 1 — Add `AdminUiPolicies` constants
+
+**Classification:** small
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none (everything depends on it)
+**Files:** create `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs`
+
+Add the four-constant static class exactly as in the design (values `"FleetAdmin"`, `"DriverOperator"`,
+`"ConfigEditor"`, `"AuthenticatedRead"`). Build the Security project. No commit yet (commits with Task 4).
+
+### Task 2 — Policy-semantics unit test (RED)
+
+**Classification:** small (TDD red)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 3
+**Files:** create `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`
+
+Write the `IAuthorizationService` matrix test per the design (mirror `AddOtOpcUaAuthWiringTests` provider
+construction). Include the four-policy matrix + the `DevAuthRoles.All` assertion. Run:
+
+```bash
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~AdminUiPoliciesTests"
+```
+
+**Must FAIL:** `ConfigEditor`/`AuthenticatedRead` are not registered (`InvalidOperationException: No policy found`).
+The `FleetAdmin`/`DriverOperator` cases pass already — keep them in the same class as pins.
+
+### Task 3 — Reflection page-authorization guard (RED — must list the ungated pages)
+
+**Classification:** small (TDD red; the load-bearing guard)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 2
+**Files:** create `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs`
+(+ possibly `` in the test csproj)
+
+Implement Rules 1–3 over `typeof(Routes).Assembly` with the full 38-type expected map from the design (compile-time
+`typeof(...)` references). Run:
+
+```bash
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~PageAuthorizationGuardTests"
+```
+
+**Must FAIL listing every offender by route:** 31 bare-`[Authorize]` pages, `Home` (no attribute), and
+`Deployments`/`Scripts`/`ScriptEdit` (Roles idiom) — ~35 total; only `RoleGrants` + `Login` classify clean.
+Commit nothing yet.
+
+### Task 4 — Register `ConfigEditor` + `AuthenticatedRead`; constants for existing policies (GREEN for Task 2)
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 5 (after Task 1)
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` (:143-161)
+
+Add the two `AddPolicy` blocks from the design; switch `"DriverOperator"`/`"FleetAdmin"` literals to the
+constants. Re-run the Task 2 filter — **all green**.
+**Commit:** `feat(security): add ConfigEditor + AuthenticatedRead AdminUI policies behind AdminUiPolicies constants` (Tasks 1+2+4; include the still-red guard? No — the guard lands with Task 11's final green).
+
+### Task 5 — `_Imports.razor` usings + de-dupe
+
+**Classification:** small
+**Estimated implement time:** ~2 min
+**Parallelizable with:** Task 4
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor`; `Components/Pages/Deployments.razor` (:2);
+`Components/Pages/Alerts.razor` (:7)
+
+Append `@using Microsoft.AspNetCore.Authorization` + `@using ZB.MOM.WW.OtOpcUa.Security.Auth` to `_Imports.razor`;
+delete the now-duplicate in-file `@using Microsoft.AspNetCore.Authorization` lines from Deployments/Alerts. Build
+AdminUI.
+
+### Task 6 — Gate UNS pages (ConfigEditor)
+
+**Classification:** small (mechanical)
+**Estimated implement time:** ~2 min
+**Parallelizable with:** Tasks 7, 8, 9, 10
+**Files:** `Components/Pages/Uns/GlobalUns.razor` (:2), `Components/Pages/Uns/EquipmentPage.razor` (:7)
+
+Replace each `@attribute` line with `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`.
+
+### Task 7 — Gate cluster editors (ConfigEditor)
+
+**Classification:** small (mechanical)
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Tasks 6, 8, 9, 10
+**Files:** `Components/Pages/Clusters/NewCluster.razor` (:2), `ClusterEdit.razor` (:4), `NodeEdit.razor` (:5),
+`NamespaceEdit.razor` (:6), `AclEdit.razor` (:3)
+
+Same replacement, `ConfigEditor`.
+
+### Task 8 — Gate driver pages + routers (ConfigEditor)
+
+**Classification:** small (mechanical)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Tasks 6, 7, 9, 10
+**Files:** `Components/Pages/Clusters/Drivers/`: `DriverTypePicker.razor` (:4), `DriverEditRouter.razor` (:5),
+`ModbusDriverPage.razor`, `S7DriverPage.razor`, `AbCipDriverPage.razor`, `AbLegacyDriverPage.razor`,
+`TwinCATDriverPage.razor`, `FocasDriverPage.razor`, `OpcUaClientDriverPage.razor`, `GalaxyDriverPage.razor` (all :2)
+
+Same replacement, `ConfigEditor` — this is the surface that authors live `ResilienceConfig`.
+
+### Task 9 — Converge role-string pages onto ConfigEditor
+
+**Classification:** small (mechanical)
+**Estimated implement time:** ~2 min
+**Parallelizable with:** Tasks 6, 7, 8, 10
+**Files:** `Components/Pages/Deployments.razor` (:12 — note line shifts if Task 5 removed :2), `Scripts.razor` (:2),
+`ScriptEdit.razor` (:5)
+
+Replace the `Roles = "Administrator,Designer"` attributes with `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`.
+Semantics identical (same two roles).
+
+### Task 10 — Read pages to AuthenticatedRead; RoleGrants to constant; Home gains its attribute
+
+**Classification:** small (mechanical)
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Tasks 6, 7, 8, 9
+**Files:** `Components/Pages/`: `Home.razor` (insert :2), `Fleet.razor` (:6), `Hosts.razor` (:7), `Alerts.razor`
+(:5), `ScriptLog.razor` (:4), `AlarmsHistorian.razor` (:4), `Account.razor` (:6), `Certificates.razor` (:2),
+`Reservations.razor` (:2), `RoleGrants.razor` (:2), `Clusters/ClustersList.razor`, `ClusterOverview.razor`,
+`ClusterAudit.razor`, `ClusterAcls.razor`, `ClusterNamespaces.razor`, `ClusterDrivers.razor`,
+`ClusterRedundancy.razor` (all :2)
+
+Read pages → `@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]`;
+`RoleGrants.razor` → `@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]`.
+
+### Task 11 — Guard goes GREEN; commit the gate
+
+**Classification:** small (TDD green checkpoint)
+**Estimated implement time:** ~3 min
+**Parallelizable with:** none (join point for 5–10)
+**Files:** none (verification)
+
+```bash
+dotnet build ZB.MOM.WW.OtOpcUa.slnx
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~PageAuthorizationGuardTests"
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests
+```
+
+Guard must be fully green (all 38 pages classified + matching); full AdminUI.Tests green (443 existing + new).
+**Commit:** `fix(adminui): gate every page with named policies — ConfigEditor on the mutating surface (R2-05, 04/C-1)`
+(Tasks 3+5–11).
+
+### Task 12 — Converge non-page literals onto the constants
+
+**Classification:** small
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 13
+**Files:** `ScriptAnalysis/ScriptAnalysisEndpoints.cs` (:18 + comment/xmldoc), `Components/Pages/Alerts.razor`
+(:165), `Components/Shared/Drivers/DriverStatusPanel.razor` (:166),
+`Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` (:124),
+`Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor` (:75),
+`Components/Pages/Certificates.razor` (:90, :186)
+
+Per implementation step 5. Then:
+
+```bash
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ScriptAnalysis"
+```
+
+**Commit:** `refactor(adminui): replace authorization string literals with AdminUiPolicies constants`.
+
+### Task 13 — Docs: CLAUDE.md + security guide
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 12
+**Files:** `CLAUDE.md` (ScriptAnalysis gating sentence), `docs/security.md` (grep `FleetAdmin`/`Administrator,Designer`/
+AdminUI gating statements and align), `archreview/plans/STATUS.md` (add the R2-05 row)
+
+Doc-drift discipline (the C-2 lesson): the gate and its documentation change in the same PR.
+**Commit:** `docs(security): document AdminUI policy tiers (ConfigEditor/AuthenticatedRead) + R2-05 status`.
+
+### Task 14 — Full-solution test sweep
+
+**Classification:** small (verification)
+**Estimated implement time:** ~5 min (wall time longer; single command)
+**Parallelizable with:** none
+**Files:** none
+
+```bash
+dotnet test ZB.MOM.WW.OtOpcUa.slnx
+```
+
+All unit suites green (integration suites needing fixtures skip cleanly per repo posture). Fix any straggler
+(most likely: a page file whose attribute line number shifted — the guard's failure message names it).
+
+### Task 15 — Live `/run` verification on docker-dev (positive/regression pass)
+
+**Classification:** live-verify (required; cannot be skipped — no bUnit)
+**Estimated implement time:** ~20 min
+**Parallelizable with:** none
+**Files:** none
+
+Rebuild **both** central-1 and central-2 images (`:9200` round-robins between them), `docker-dev` up, then with
+the auto-admin session (login disabled ⇒ auto-authenticated full-access admin — **positive testing only; the
+negative/deny proof is Tasks 2+3, not this rig**): drive `/` → `/uns` (create + delete a scratch area) →
+`/uns/equipment/{id}` → `/clusters` → `/clusters/{id}/drivers` → a driver page (open resilience section) →
+`/deployments` (deploy) → `/role-grants` → `/alerts` → `/certificates`. Every page must render and mutate as
+before; any permission-denied slot seen by the auto-admin is an over-gating bug (typo'd policy name). *Optional
+stretch:* one container with `Security__Auth__DisableLogin=false` against shared GLAuth `10.100.0.35:3893`, bind a
+Viewer user, observe the `/uns` deny + `/clusters` allow.
+
+### Task 16 — Merge + bookkeeping
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none
+**Files:** `archreview/04-adminui.md` (C-1 row → FIXED w/ commit), `archreview/plans/STATUS.md`
+
+Merge `fix/archreview-r2-05-adminui-authz` to master (user's call on push), flip the 04/C-1 prior-finding row,
+record the two verification corrections (Reservations/ClusterRedundancy read-only) in STATUS.md.
+
+---
+
+**Total: 16 tasks · overall effort Small-Medium (~2.5–4 h including the live pass).**
diff --git a/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json
new file mode 100644
index 00000000..3fa64da1
--- /dev/null
+++ b/archreview/plans/R2-05-adminui-authz-plan.md.tasks.json
@@ -0,0 +1,22 @@
+{
+  "planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    { "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "pending", "blockedBy": [] },
+    { "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "pending", "blockedBy": ["T2"] },
+    { "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "pending", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
+    { "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "pending", "blockedBy": ["T12", "T13"] },
+    { "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "pending", "blockedBy": ["T14"] },
+    { "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "pending", "blockedBy": ["T15"] }
+  ]
+}
diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md b/archreview/plans/R2-06-serverhistorian-failfast-plan.md
new file mode 100644
index 00000000..55edd5fc
--- /dev/null
+++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md
@@ -0,0 +1,247 @@
+# 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` (shared `ZB.MOM.WW.Configuration`, `ServiceCollectionExtensions.cs:28-41`: bind + `IValidateOptions` + `ValidateOnStart`) over `OptionsValidatorBase`/`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`** 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("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 ('') — 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(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 ''. …")`. 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 (`` 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` + in-memory config with `Enabled=true`/empty endpoint → resolving `IOptions.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=  # 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 + `` 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().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 `` 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`; ctor `IConfiguration`; `consumed = options.Enabled || _configuration.GetValue("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(config, ServerHistorianOptions.SectionName)` with enabled+empty in-memory config; resolving `IOptions().Value` → `Should.Throw` 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`) 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 + ``), `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__` (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.
diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
new file mode 100644
index 00000000..b50deccb
--- /dev/null
+++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
@@ -0,0 +1,78 @@
+{
+  "planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    {
+      "id": "T1",
+      "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "T2",
+      "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
+      "status": "pending",
+      "blockedBy": ["T1"]
+    },
+    {
+      "id": "T3",
+      "subject": "RED: ServerHistorianOptionsValidator unit tests (7 cases incl. the AlarmHistorian-enabled/ServerHistorian-disabled corner naming both sections)",
+      "status": "pending",
+      "blockedBy": ["T1"]
+    },
+    {
+      "id": "T4",
+      "subject": "GREEN: implement ServerHistorianOptionsValidator (OptionsValidatorBase, ctor IConfiguration, consumed = Enabled || AlarmHistorian:Enabled, fail on empty/non-absolute/non-http(s) Endpoint)",
+      "status": "pending",
+      "blockedBy": ["T3"]
+    },
+    {
+      "id": "T5",
+      "subject": "Wiring: AddValidatedOptions in Program.cs hasDriver block + OptionsValidationException wiring test + residual alarm-only-mode startup warning",
+      "status": "pending",
+      "blockedBy": ["T4"]
+    },
+    {
+      "id": "T6",
+      "subject": "Warning parity: malformed-endpoint warning in ServerHistorianOptions.Validate() + Runtime.Tests case; commit 1 (S-11) after T1-T6 green",
+      "status": "pending",
+      "blockedBy": ["T4"]
+    },
+    {
+      "id": "T7",
+      "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "T8",
+      "subject": "U-7 live leg: HISTGW_BOOL_SANDBOX_TAG in GatewayLiveFixture + EnsureTags_boolean_sandbox_provisions_as_Int1 + EnsureTags_type_change_outcome_is_observable retype probe (skip-clean offline; verify all-skip run)",
+      "status": "pending",
+      "blockedBy": ["T7"]
+    },
+    {
+      "id": "T9",
+      "subject": "U-7 docs: migration/assessment note (EnsureTags is upsert; pre-bump Float Booleans; three retype hypotheses; failed=N tally observability) in docs/Historian.md + CLAUDE.md HISTGW_BOOL_SANDBOX_TAG recipe update; commit 2 (U-7) after T7-T9",
+      "status": "pending",
+      "blockedBy": ["T8"]
+    },
+    {
+      "id": "T10",
+      "subject": "C-7: keep-populating decision + dead-Quality doc comments at GatewayHistorianValueWriter.cs:63, ContinuousHistorizationRecorder.GoodQuality, docs/Historian.md (cites 0.2.0 Contracts C-002); commit 3 (C-7)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "T11",
+      "subject": "Bookkeeping: update archreview/plans/STATUS.md + 06-gateway-integrations.md prior-finding rows for S-11/U-7/C-7 when the work lands",
+      "status": "pending",
+      "blockedBy": ["T6", "T9", "T10"]
+    },
+    {
+      "id": "T12",
+      "subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run",
+      "status": "pending",
+      "blockedBy": ["T9", "T11"]
+    }
+  ]
+}
diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md b/archreview/plans/R2-07-surgical-pure-adds-plan.md
new file mode 100644
index 00000000..ff9b466f
--- /dev/null
+++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md
@@ -0,0 +1,371 @@
+# Design & Implementation Plan — R2-07: Surgical pure-adds (and removals) in the address-space applier
+
+> **Status (2026-07-12):** NOT STARTED — this is the round-2 plan. The declared prerequisite
+> (03/U2 reflection-exhaustive deferred-sink forwarding guard, `DeferredSinkForwardingReflectionTests`)
+> **is merged to master** and auto-covers every new sink capability member this plan adds, so the
+> F10b/PR#423 inertness class is retired and the work is unblocked.
+
+- **Source reports:** `archreview/03-server-runtime.md` (P1 — the primary finding), `archreview/01-core-composition.md` (applier/planner context, S-1, C-7), `archreview/00-OVERALL.md` prioritized action **#7** ("Surgical pure-adds in the applier — Large effort, High impact, biggest operational perf win").
+- **Round-1 plan extended:** `archreview/plans/03-server-runtime-plan.md` §P1 (this plan supersedes its two-phase sketch with a full three-phase staged design).
+- **Review commit:** `f6eaa267` · **Plan verified against tree at:** `f6eaa267` (master, clean tree; archreview/*.md working-tree updates from the 2026-07-12 re-review read in-place).
+- **Scope:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/` (`AddressSpaceApplier`, `AddressSpacePlan`/`AddressSpacePlanner`, `OtOpcUaNodeManager`, `SdkAddressSpaceSink`), `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/` (`ISurgicalAddressSpaceSink`, `DeferredAddressSpaceSink`, `IOpcUaAddressSpaceSink`), `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`, plus tests under `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/`, `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`, `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/`.
+
+## Verification summary
+
+Every claim below re-verified by direct read at `f6eaa267`:
+
+| # | Claim | Verified at |
+|---|-------|-------------|
+| 1 | Any add/remove of Equipment / ScriptedAlarm / EquipmentTag / EquipmentVirtualTag forces `structuralRebuild` → `SafeRebuild()` | `AddressSpaceApplier.cs:134-154` — the predicate ORs **every** `Added*`/`Removed*` count |
+| 2 | `RebuildAddressSpace()` detaches + removes **every** `NodeState` (variables, conditions, folders, notifier refs) under one `Lock` | `OtOpcUaNodeManager.cs:1690-1736` |
+| 3 | Recreated nodes are new `NodeState` instances ⇒ existing client MonitoredItems go permanently dead | SDK binds monitored items to `NodeState` instances; confirmed by the F10b design history and `ISurgicalAddressSpaceSink` doc (`ISurgicalAddressSpaceSink.cs:9-17`) |
+| 4 | The surgical path today covers ONLY changed-tag attribute edits (`TagDeltaIsSurgicalEligible`) + folder DisplayName renames (`RenamedFolders`) — **not adds, not removes** | `AddressSpaceApplier.cs:155-199, 630-663` |
+| 5 | The four Materialise passes run unconditionally after `Apply` returns, over the FULL composition, and are **idempotent** (`EnsureFolder` early-return `OtOpcUaNodeManager.cs:1290`, `EnsureVariable` early-return `:1376`, `MaterialiseAlarmCondition` drop-and-recreate guard `:596-604`) | `OpcUaPublishActor.cs:335-354` (passes at 338/343/349/354, gated on the `plan.IsEmpty` short-circuit at :328) |
+| 6 | `RaiseNodesAddedModelChange` (Part 3 `GeneralModelChangeEvent`, verb `NodeAdded`, built under `Lock`, reported outside it) already exists, is on `IOpcUaAddressSpaceSink`, is forwarded by `DeferredAddressSpaceSink`, and is guard-covered | `OtOpcUaNodeManager.cs:1592-1654`, `IOpcUaAddressSpaceSink.cs:88-94`, `DeferredAddressSpaceSink.cs:49`; `NodeManagerModelChangeOnAddTests.cs` exists |
+| 7 | The U2 prerequisite guard is merged: `DeferredSinkForwardingReflectionTests` (Commons.Tests) reflects over every member of every forwarding interface of `DeferredAddressSpaceSink` via a `DispatchProxy` recorder that **auto-implements future members**, plus a guard-of-the-guard pinning the interface set | `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs:29-75` |
+| 8 | `FeedHistorizedRefs` + `ProvisionHistorizedTags` operate on the **plan's** add/remove/change deltas and fire at the end of `Apply` unconditionally — independent of rebuild-vs-surgical routing | `AddressSpaceApplier.cs:208-213, 226-353` |
+| 9 | The driver-side routing maps (`_nodeIdByDriverRef` / `_driverRefByNodeId` / `_alarmNodeIdByDriverRef`) and desired-subscription sets are rebuilt **every apply** by `DriverHostActor.PushDesiredSubscriptions`, fully independent of the node-manager rebuild | `DriverHostActor.cs:107-158` (doc: "Rebuilt every apply") |
+| 10 | Variable NodeIds are folder-scoped strings derived deterministically from composition (`EquipmentNodeIds.Variable/SubFolder`), all in the one server namespace (`NamespaceIndex`) — adds cannot shift existing NodeIds | `EquipmentNodeIds.cs`, `OtOpcUaNodeManager.cs:1299, 1391` |
+| 11 | The applier already writes a terminal condition state (`RemovedConditionState`) to removed equipment/alarm condition nodes **before** the teardown | `AddressSpaceApplier.cs:83-98, 665-681` |
+| 12 | 03/P1 is **STILL OPEN** at f6eaa267 ("guard merged, work not started") | `archreview/03-server-runtime.md` prior-finding table row P1 + §2 P1 |
+
+**Conclusion: the finding fully reproduces.** Adding one tag to one equipment still tears down and recreates every node on the server, killing every client subscription server-wide. The striking verification result (claims 5+6) is that **Phase 1 (pure-adds) requires no new sink capability at all** — the idempotent Materialise passes already create exactly the new nodes when the rebuild is skipped; the only missing pieces are (a) a classifier that routes a pure-add plan away from `SafeRebuild()` and (b) a post-materialise `NodeAdded` model-change announcement. Removals (Phase 2) are where new sink members are needed.
+
+---
+
+## Restatement
+
+`AddressSpaceApplier.Apply` treats every topology delta identically: any added or removed equipment, scripted alarm, equipment tag, or virtual tag sets `structuralRebuild = true`, which calls `sink.RebuildAddressSpace()` → `OtOpcUaNodeManager.RebuildAddressSpace()` clears all five node maps and detaches every `NodeState`. The subsequent Materialise passes recreate the world as **new** `NodeState` instances. OPC UA monitored items are bound to the old instances, so every client subscription on the server goes dead — for a deploy that added a single tag. On a production SCADA server with dozens of clients (including ScadaBridge's Data Connection Layer), every authoring action that touches topology is a server-wide subscription outage. The F10b surgical path (`ISurgicalAddressSpaceSink.UpdateTagAttributes` / `UpdateFolderDisplayName`) proves in-place mutation preserves subscriptions, but covers only in-place attribute edits and UNS folder renames.
+
+## Verification
+
+Confirmed — see the table above. Key mechanics re-checked at f6eaa267:
+
+- **The rebuild predicate** (`AddressSpaceApplier.cs:134-140`): `AddedEquipment.Count > 0 || RemovedEquipment.Count > 0 || ChangedEquipment.Count > 0 || AddedAlarms/RemovedAlarms/ChangedAlarms || AddedEquipmentTags/RemovedEquipmentTags || ChangedEquipmentTags.Any(!TagDeltaIsSurgicalEligible) || AddedEquipmentVirtualTags/RemovedEquipmentVirtualTags || ChangedEquipmentVirtualTags.Any(!VtagDeltaIsNodeIrrelevant)`. Pure adds land in this branch.
+- **The apply pipeline** (`OpcUaPublishActor.HandleRebuild`, `OpcUaPublishActor.cs:290-364`): load artifact → `ParseComposition` → `AddressSpacePlanner.Compute(_lastApplied, composition)` → **`plan.IsEmpty` short-circuit** (:328 — the known IsEmpty-gate: the Materialise passes do NOT run on an empty plan, so anything the plan is blind to never materialises) → `_applier.Apply(plan)` → the four Materialise passes over the full composition (:338-354). A pure-add plan is non-empty, so the passes always run for it.
+- **Idempotency of the passes**: `EnsureFolder`/`EnsureVariable` early-return on an existing NodeId; `MaterialiseAlarmCondition` drops-and-recreates per id (idempotent but **not** subscription-preserving for that one condition node — see Design, native-alarm note). `MaterialiseHierarchy`/`MaterialiseEquipmentTags`/`MaterialiseEquipmentVirtualTags`/`MaterialiseScriptedAlarms` therefore add exactly the new nodes and no-op existing ones when no rebuild cleared the maps.
+- **Folder-scoped NodeIds** (the second known gotcha): variables materialise at `EquipmentNodeIds.Variable(equipmentId, folderPath, name)` — NOT the driver FullName — so add-only deploys cannot collide with or re-key existing nodes; the driver value router (`_nodeIdByDriverRef`) is rebuilt per apply from the same composition and needs no change.
+- **The guard**: `DeferredSinkForwardingReflectionTests.Every_forwarding_interface_method_reaches_the_inner_sink` iterates `ForwardingInterfaces = { IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink }` via `GetMethods()` — a member added to `ISurgicalAddressSpaceSink` in Phase 2 is picked up with **zero test edits** and fails the guard if `DeferredAddressSpaceSink` does not forward it.
+
+## Root cause
+
+The applier's rebuild predicate conflates three delta classes with very different minimal-mutation requirements:
+
+1. **In-place changes** (attribute edits, renames) — already handled surgically (F10b).
+2. **Pure additions** — need *no teardown at all*: the idempotent Materialise passes add exactly the new nodes; `RaiseNodesAddedModelChange` exists to announce them. The rebuild is pure collateral damage.
+3. **Removals / node-affecting mutations** — genuinely need teardown, but only of the affected subtree, not the world.
+
+The predicate collapses all three into one boolean. The fix is a classifier that names the classes and routes each to its minimal mutation, with full rebuild as the safety valve for anything else.
+
+---
+
+## Proposed design
+
+### The delta classifier
+
+A new pure static `AddressSpaceChangeClassifier` in `ZB.MOM.WW.OtOpcUa.OpcUaServer` (same project as the planner — it is a pure function over `AddressSpacePlan`, unit-testable without any sink), returning:
+
+```csharp
+/// How AddressSpaceApplier must mutate the address space for a given plan.
+public enum AddressSpaceChangeKind
+{
+    /// Plan.IsEmpty — nothing to do.
+    Empty,
+    /// Only surgical-eligible changed tags, node-irrelevant vtag changes, and/or folder
+    /// renames — the existing F10b in-place path. No adds, no removes.
+    AttributeOnly,
+    /// Only additions (any mix of Equipment/Alarms/Tags/VirtualTags/Drivers) on top of
+    /// at-most-AttributeOnly changes — no removals, no node-affecting changes. Phase 1.
+    PureAdd,
+    /// Only removals on top of at-most-AttributeOnly changes — no additions, no
+    /// node-affecting changes. Phase 2.
+    PureRemove,
+    /// Additions AND removals, still no node-affecting changes. Phase 3 applies
+    /// removes-then-adds surgically; until Phase 3 it maps to Rebuild.
+    AddRemoveMix,
+    /// Anything with a node-affecting change (ChangedEquipment, ChangedAlarms,
+    /// non-surgical ChangedEquipmentTags, node-relevant ChangedEquipmentVirtualTags) — the
+    /// unclassifiable-safe default. Full rebuild.
+    Rebuild,
+}
+
+public static class AddressSpaceChangeClassifier
+{
+    public static AddressSpaceChangeKind Classify(AddressSpacePlan plan);
+}
+```
+
+Classification algorithm (evaluated top-down; **first match wins**):
+
+| Order | Condition | Kind |
+|---|---|---|
+| 1 | `plan.IsEmpty` | `Empty` |
+| 2 | `ChangedEquipment.Count > 0` **or** `ChangedAlarms.Count > 0` **or** `ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d))` **or** `ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))` | `Rebuild` |
+| 3 | `hasAdds && hasRemoves` | `AddRemoveMix` |
+| 4 | `hasAdds` | `PureAdd` |
+| 5 | `hasRemoves` | `PureRemove` |
+| 6 | otherwise (only surgical tag deltas / node-irrelevant vtag deltas / folder renames / driver deltas) | `AttributeOnly` |
+
+where `hasAdds = AddedEquipment.Count + AddedAlarms.Count + AddedEquipmentTags.Count + AddedEquipmentVirtualTags.Count > 0` and `hasRemoves = RemovedEquipment.Count + RemovedAlarms.Count + RemovedEquipmentTags.Count + RemovedEquipmentVirtualTags.Count > 0`. **Driver deltas (`Added/Removed/ChangedDrivers`) are node-inert** — they never touch the address space today (excluded from the current predicate, routed to `DriverSpawnPlanner` in Runtime) and stay excluded from `hasAdds`/`hasRemoves`. Note a PureAdd/PureRemove/AddRemoveMix plan may *also* carry surgical tag deltas and folder renames — the applier applies those via the existing F10b path in the same pass (they are orthogonal in-place mutations).
+
+The two eligibility predicates (`TagDeltaIsSurgicalEligible`, `VtagDeltaIsNodeIrrelevant`, currently private statics at `AddressSpaceApplier.cs:630-663`) **move to the classifier** (as `internal static`, applier keeps thin delegating calls or references them directly) so the whole classification is one pure, table-testable unit. Their whitelist-via-`with`-expression technique is preserved byte-for-byte (01 report calls it out as a positive).
+
+### Phase staging
+
+**Phase 1 — pure-adds (the ~90% win, no new sink members).** When `Classify == PureAdd`:
+
+1. `Apply` does **not** call `SafeRebuild()`. It still applies any coincident folder renames + surgical tag deltas via the existing `ISurgicalAddressSpaceSink` path (any `false`/throw there → `SafeRebuild()` fallback, exactly as today).
+2. The four Materialise passes in `OpcUaPublishActor.HandleRebuild` then run as they always do and create **exactly** the added nodes (folders/variables/conditions), idempotently no-op'ing everything that already exists. No applier change needed for the creation itself — this is the load-bearing simplification. The only pass with a wrinkle is `MaterialiseScriptedAlarms`/native-alarm materialisation: `MaterialiseAlarmCondition` is drop-and-recreate per id, so on a pure-add apply it would drop-and-recreate every **existing enabled** condition node (killing monitored items on those condition nodes only). Fix: make `MaterialiseAlarmCondition` **skip-if-present-and-same-kind** on the node-manager side — add an idempotent early-return when `_alarmConditions` already holds the id with the same `isNative` flag (drop-and-recreate stays for the kind-swap/re-severity case, which only reaches this path via a rebuild where the map was just cleared). This keeps existing alarm subscriptions alive across a pure-add deploy and is behavior-identical on the rebuild path.
+3. **Announcement:** after the Materialise passes complete, `OpcUaPublishActor` calls a new `applier.AnnounceAddedNodes(plan)` (Phase-1 applier method, no new sink member — it uses the existing, guard-covered `RaiseNodesAddedModelChange`). It computes a **deduplicated set of affected parent ids**: for each added tag/vtag → its materialise parent (`EquipmentId` or `EquipmentNodeIds.SubFolder(EquipmentId, FolderPath)`); for each added scripted alarm → its `EquipmentId`; for each added equipment → its `UnsLineId` when present, else the equipment's own id (the root has no announceable id; announcing the new folder itself is valid Part 3 — verb `NodeAdded`, `Affected` = the added node). One `RaiseNodesAddedModelChange` per distinct id. Ordering is correct by construction: `Apply` returns before the passes run, and the announce runs after them, so the nodes exist when clients re-browse. The announce is gated on `!outcome.RebuildCalled` (after a full rebuild the announcement is moot — subscriptions are dead anyway, unchanged behavior).
+4. **Existing-subscription behavior on add: none affected** — no existing `NodeState` is touched; per OPC UA Part 4 monitored items are unaffected by address-space growth; model-aware clients get the Part 3 `GeneralModelChangeEvent` and re-browse.
+
+**Phase 2 — pure-removes (new sink capability).** When `Classify == PureRemove`:
+
+New members on **`ISurgicalAddressSpaceSink`** (existing interface — the guard's `ForwardingInterfaces` set needs **no** change; the `DispatchProxy` recorder auto-covers new members):
+
+```csharp
+/// Remove ONE existing value-variable node in place (detach from parent, drop from the
+/// predefined-node set, drop the historized-tagname registration), then raise a Part 3
+/// GeneralModelChangeEvent (verb=NodeDeleted) outside the lock. The caller has already written a
+/// final Bad quality to the node so in-flight MonitoredItems observe the removal. Returns false
+/// when the node id is unknown (caller falls back to a full rebuild — map-drift safety valve).
+bool RemoveVariableNode(string variableNodeId);
+
+/// Remove ONE existing Part 9 alarm-condition node in place (detach, drop from the
+/// predefined-node set, drop the native-alarm flag), then raise NodeDeleted outside the lock. The
+/// caller has already written the terminal RemovedConditionState (Retain=false). Returns false
+/// when the condition id is unknown (caller rebuilds).
+bool RemoveAlarmConditionNode(string alarmNodeId);
+
+/// Remove an equipment folder AND its entire descendant subtree (sub-folders, variables,
+/// condition nodes) in place: per-descendant map/registration cleanup (variables, historized
+/// tagnames, conditions, native flags), notifier demotion (RemoveRootNotifier + notifier-folder +
+/// event-notifier-source registrations for the equipment id), then one NodeDeleted for the
+/// equipment folder outside the lock. Returns false when the folder id is unknown (caller
+/// rebuilds).
+bool RemoveEquipmentSubtree(string equipmentNodeId);
+```
+
+Implemented on `OtOpcUaNodeManager` (all mutation under `Lock`; the `NodeDeleted` model-change event built under `Lock` and reported after release, mirroring `RaiseNodesAddedModelChange` / `ReportNodeShapeChangedEvent`), forwarded by `SdkAddressSpaceSink`, capability-sniff-forwarded by `DeferredAddressSpaceSink` (`_inner is ISurgicalAddressSpaceSink s && s.X(...)` — false pre-swap ⇒ rebuild fallback, same as `UpdateTagAttributes`), and no-op'd nowhere else (`NullOpcUaAddressSpaceSink` doesn't implement the surgical interface — unchanged).
+
+Applier routing for `PureRemove`:
+
+1. Partition removals: removed equipment "own" their child tags/vtags/alarms — a `RemovedEquipmentTags`/`RemovedEquipmentVirtualTags`/`RemovedAlarms` entry whose `EquipmentId` is in `RemovedEquipment` is **subsumed** by the subtree removal and skipped individually.
+2. Pre-teardown terminal writes (extends the existing `:83-98` pattern): removed value variables get `WriteValue(nodeId, null, Bad, ts)`; removed condition nodes (scripted alarms AND alarm-bearing tags — the latter is a today-gap: `RemovedEquipmentTags` with `Alarm != null` currently get no condition write) get `SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)`. This is what an in-flight MonitoredItem observes (final Bad notification), which is the spec-friendly signal; after the node is gone, re-subscription attempts get `BadNodeIdUnknown` from the SDK because the node is no longer in the predefined set.
+3. Teardown order children-first: individual variable/condition removals (for surviving equipment), then `RemoveEquipmentSubtree` per removed equipment. Empty leftover `FolderPath` sub-folders on surviving equipment are **deliberately kept** (browse-visible but harmless; the next full rebuild collects them — documented v1 cut, mirrors `EnsureFolder`'s create-only semantics).
+4. Any `false`/throw from a remove ⇒ `SafeRebuild()` (one-way ratchet per apply: once rebuilt, skip remaining surgical work — the rebuild already reconciled everything).
+5. **Subscription behavior on remove (per spec):** subscribers of *other* nodes are untouched; subscribers of the removed node see a final Bad value (step 2), the node disappears from browse, and model-aware clients get `NodeDeleted`. This matches OPC UA Part 3/4 expectations for node deletion.
+
+**Phase 3 — mixed add+remove batches.** When `Classify == AddRemoveMix`: apply the Phase-2 remove pass, then let the Materialise passes + Phase-1 announce handle the adds — removes-then-adds within one apply. The one ordering hazard is an id **reused** across the remove and add sets in the same deploy (e.g. tag removed from equipment E folder F name N while another tag materialises at the identical folder-scoped NodeId): removes run in `Apply` (before the passes), adds run in the passes (after), so remove-then-recreate is the natural order and the recreated node is a fresh `NodeState` (correct — it is a different tag). No special-casing needed; covered by a targeted test. Until Phase 3 ships, `AddRemoveMix` maps to `Rebuild` (today's behavior), which is why each phase is independently shippable.
+
+### NodeId / namespace-index stability constraints
+
+- All materialised nodes live in the single server namespace (`NamespaceIndex` on `OtOpcUaNodeManager`); the index is assigned at server start and is untouched by deploys. No constraint is added or relaxed by this work.
+- Folder/variable/condition NodeIds are deterministic pure functions of composition (`UnsAreaId`/`UnsLineId`/`EquipmentId`, `EquipmentNodeIds.SubFolder/Variable`, `ScriptedAlarmId`) — an add cannot re-key an existing node, and the surgical paths reuse the **same** derivations the Materialise passes use (the applier already does this for `UpdateTagAttributes` at `:177`). Any new derivation in the remove path MUST go through `EquipmentNodeIds` — never hand-rolled strings.
+- Same-NodeId collision on add (two tags materialising to one folder-scoped id) keeps today's first-wins `EnsureVariable` semantics — no regression, explicitly out of scope.
+
+### Interaction with the historian/provisioning/alarm seams (all verified no-change or additive)
+
+- **Historized-ref feed (`FeedHistorizedRefs`)** and **historian provisioning (`ProvisionHistorizedTags`)** already consume the plan's add/remove/change deltas and fire unconditionally at the end of `Apply` — routing a plan surgically instead of via rebuild changes nothing for them. (They are the design template for this work: per-deploy delta consumption, non-blocking, deploy-safe.)
+- **Native alarm condition nodes:** added alarm-bearing tags materialise via `SafeMaterialiseAlarmCondition` in `MaterialiseEquipmentTags` — additive on the pure-add path once the skip-if-present guard (Phase 1 step 2) lands. Removal of an alarm-bearing tag routes to `RemoveAlarmConditionNode` (its node is a condition, not a value variable). `EnsureFolderIsEventNotifier` promotion for a first-alarm-on-existing-equipment add is already runtime-safe (the rebuild path performs it on a live server today).
+- **Driver subscriptions / value routing:** rebuilt every apply by `DriverHostActor.PushDesiredSubscriptions` independent of this path — adds get subscribed, removes get unsubscribed and un-routed, no change needed.
+- **`AddressSpaceApplyOutcome`:** gains nothing structurally in Phase 1 (the existing `RebuildCalled=false` + `AddedNodes` counts express the pure-add outcome); the apply log line gains the classifier verdict (`kind={Kind}`) so live verification can grep for `kind=PureAdd, rebuild=False`.
+
+### Fallback-to-rebuild safety valve
+
+Single principle, enforced by the classifier's shape: **anything not positively classified as safe falls into `Rebuild`** (order rule 2 catches every node-affecting change, including any future plan field that makes a changed record unequal — the `with`-expression whitelists are default-closed). At apply time, every surgical sink call keeps the F10b contract: `false` or throw ⇒ `SafeRebuild()` and mark `rebuilt`. Net: the worst possible outcome of any bug in this work is today's behavior (a full rebuild).
+
+### Alternatives considered (and rejected)
+
+- **Re-home monitored items onto recreated nodes after a rebuild** — the SDK offers no supported API to re-bind a `MonitoredItem` to a new `NodeState`; rejected (round-1 plan concurs).
+- **Generic diff-and-patch of every delta class** (make `ChangedEquipment`/`ChangedAlarms` surgical too) — larger and riskier; changed-alarm re-materialisation and equipment renames can ride a later increment (equipment DisplayName rename is `UpdateFolderDisplayName`-shaped — noted as follow-up, not in scope). The add/remove split follows the plan's existing typed delta sets.
+- **Classify in `AddressSpacePlanner` (attach the kind to the plan record)** — rejected: `Compute` is a pure diff and the kind is a *policy* over the diff that the applier owns (and the F10b eligibility predicates it needs already live applier-side). A separate pure classifier keeps the planner untouched and the policy table-testable.
+- **Batch remove member (`RemoveNodes(IReadOnlyList<…>)`) instead of three typed members** — rejected: the per-node `bool` false-means-rebuild contract (the established F10b posture) gets murky on a batch; three members mirror the existing surgical-member shape and the guard covers each independently.
+- **Return `true` (idempotent success) from a remove of an unknown node** — rejected: an unknown id on remove means the node-manager maps drifted from what the planner believes; the house posture (F10b `UpdateTagAttributes:false`) is resync-via-rebuild. Documented in the member xmldoc.
+- **Announce adds from inside `Apply`** — impossible: `Apply` runs before the Materialise passes create the nodes; the announce must be a post-passes step in `OpcUaPublishActor` (verified ordering `OpcUaPublishActor.cs:335-354`).
+
+---
+
+## Implementation steps (exact paths)
+
+### Phase 1 — pure-adds
+
+1. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs`** (new): `AddressSpaceChangeKind` enum + `Classify(AddressSpacePlan)` per the table; move `TagDeltaIsSurgicalEligible` + `VtagDeltaIsNodeIrrelevant` here as `internal static` (from `AddressSpaceApplier.cs:630-663`).
+2. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`**: replace the `structuralRebuild` boolean (`:134-140`) with `var kind = AddressSpaceChangeClassifier.Classify(plan)`; `Rebuild`/`AddRemoveMix`/`PureRemove` (until their phases ship) ⇒ `SafeRebuild()`; `PureAdd`/`AttributeOnly` ⇒ no rebuild + existing surgical renames/tag-deltas (with the existing `false`⇒rebuild fallback). Add `kind` to the apply log line. Add `public IReadOnlyList ComputeAddAnnouncements(AddressSpacePlan plan)` (pure, dedup parent-id set per the design) and `public void AnnounceAddedNodes(AddressSpacePlan plan)` calling `_sink.RaiseNodesAddedModelChange` per id (Safe-wrapped). While here, refresh the stale F14-era class doc (`:7-26`, 01/C-7).
+3. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`**: `MaterialiseAlarmCondition` (`:586-604`) — idempotent early-return when `_alarmConditions` holds the id AND the stored native flag equals `isNative` (keeps drop-and-recreate only for kind-swaps; requires remembering the flag — `_nativeAlarmNodeIds` already encodes it).
+4. **`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`**: in `HandleRebuild` after the fourth Materialise pass (`:354`), `if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);`.
+5. Live-`/run` gate (see Tests) + docs: `docs/v2/`-appropriate note + `archreview/plans/STATUS.md` row.
+
+### Phase 2 — pure-removes
+
+6. **`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs`**: add the three members (signatures above). **Guard-first discipline:** the reflection guard auto-covers them the moment they exist — implement `DeferredAddressSpaceSink` forwarding in the same commit or the guard fails the build's test leg (that failure IS the negative control; observe it once deliberately).
+7. **`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`**: capability-sniffing forwards for all three (pattern of `:58-70`).
+8. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`**: implement `RemoveVariableNode` / `RemoveAlarmConditionNode` / `RemoveEquipmentSubtree` (teardown under `Lock` mirroring the per-node loops inside `RebuildAddressSpace` `:1694-1734`, scoped to the id/subtree; clean `_variables`/`_historizedTagnames`/`_alarmConditions`/`_nativeAlarmNodeIds`/`_folders`/`_notifierFolders`(+`RemoveRootNotifier`)/`_eventNotifierSources`); `internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId)` mirroring `BuildNodesAddedModelChange` (`:1627-1654`) with `Verb = NodeDeleted`; report outside `Lock`.
+9. **`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`**: three one-line forwards.
+10. **`AddressSpaceApplier.cs`**: `PureRemove` arm — terminal writes (Bad value / `RemovedConditionState`, extending `:83-98` incl. the alarm-bearing-removed-tag gap), subsumption partition, children-first removal calls, `false`/throw ⇒ `SafeRebuild()` ratchet.
+11. Live-`/run` remove gate + docs.
+
+### Phase 3 — mixed batches
+
+12. **`AddressSpaceChangeClassifier.cs` + `AddressSpaceApplier.cs`**: `AddRemoveMix` routes to the Phase-2 remove pass then falls through to the pure-add (no-rebuild + announce) behavior; remove-then-recreate same-NodeId test.
+13. Final docs + STATUS.md close-out.
+
+---
+
+## Tests
+
+**Ordering rule: guard-set/TDD first in every task** (the F10b precedent — 258 unit tests + 3 reviews missed prod-inertness; the guard + live gate are the two nets that catch it).
+
+- **Guard extension (Phase 2, FIRST):** no test-code change needed — `DeferredSinkForwardingReflectionTests` auto-covers new `ISurgicalAddressSpaceSink` members. The TDD step is a **deliberate negative control**: add the interface members with a non-forwarding `DeferredAddressSpaceSink` stub (`=> false`), run `dotnet test --filter "FullyQualifiedName~DeferredSinkForwardingReflectionTests"` and observe the member-named FAIL, then implement the forward and observe PASS. Also extend the hand-written `DeferredAddressSpaceSinkTests` (Commons + OpcUaServer.Tests variants) with arg-fidelity cases for the three members.
+- **Classifier (Phase 1):** new `AddressSpaceChangeClassifierTests` (OpcUaServer.Tests) — table-driven (`[Theory]`/`[MemberData]`) over the full matrix: empty; each single add class ⇒ PureAdd; each single remove class ⇒ PureRemove; add+remove ⇒ AddRemoveMix; each changed class ⇒ Rebuild; surgical-eligible-changed-only ⇒ AttributeOnly; renames-only ⇒ AttributeOnly; adds+surgical-changes ⇒ PureAdd; adds+non-surgical-change ⇒ Rebuild; driver-only deltas ⇒ AttributeOnly (node-inert).
+- **Applier (Phase 1):** extend `AddressSpaceApplierTests` (the `RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink` at `:1761` already records `RebuildCalls` + `ModelChangeQueue`): pure-add plan ⇒ `RebuildCalls == 0`, `outcome.RebuildCalled == false`; `AnnounceAddedNodes` ⇒ one `RaiseNodesAddedModelChange` per distinct parent (dedup + subfolder-parent + added-equipment cases); pure-add **with** coincident surgical tag delta ⇒ no rebuild + `UpdateTagAttributes` called; surgical-`false` during a pure-add apply ⇒ rebuild fallback; remove-only and add+remove plans still rebuild (phase-boundary regression pins, updated in Phases 2/3).
+- **Node manager (Phase 1):** extend `NodeManagerSurgicalShapeUpdateTests`-style suite (or new `NodeManagerAlarmIdempotentMaterialiseTests`): re-`MaterialiseAlarmCondition` same id+kind keeps the same `AlarmConditionState` instance (`FindAlarmCondition` reference-equal); kind-swap still recreates.
+- **Node manager (Phase 2):** new `NodeManagerSurgicalRemoveTests`: ensure→remove variable ⇒ `FindVariable` null, `VariableCount` decremented, `TryGetHistorianTagname` false, unknown id ⇒ false; alarm-condition remove ⇒ `FindAlarmCondition` null + native flag cleared; equipment-subtree remove ⇒ descendants gone, notifier demoted (no orphaned root-notifier ref), other equipment untouched; `BuildNodesRemovedModelChange` populates `Verb=NodeDeleted` + `Affected` (mirrors `NodeManagerModelChangeOnAddTests`).
+- **Publish actor (Phase 1):** extend `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs`: pure-add artifact apply ⇒ sink records NO `RebuildAddressSpace` call, records `RaiseNodesAddedModelChange` AFTER `EnsureVariable` for the added tag (ordering assertion via a sequenced recording sink).
+- **Subscription-survival integration (Phase 1, the headline):** new `SubscriptionSurvivalTests` in `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` (reuse `DualEndpointTests`' in-process server + `DefaultSessionFactory` session bootstrap): materialise tag A, create a real subscription + monitored item on A, drive a pure-add apply (applier + Materialise passes) adding tag B, then (a) A's monitored item still delivers on a subsequent `WriteValue` (no `BadNodeIdUnknown`/dead item), (b) B is browsable + readable. Phase 2 sibling: subscribe A + B, surgical-remove B ⇒ A alive, B's item observes the final Bad then the node is gone from browse; Phase 3 sibling for the mixed case. This also chips at 03/U6's "OpcUaServer.IntegrationTests has one test".
+- **Live-`/run` gate (MANDATORY per phase — the F10b lesson: an optimization path that isn't live-driven can ship inert despite full unit green):** on docker-dev, **rebuild BOTH central-1 and central-2** (`:9200` round-robins them); `dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=" -i 500` and keep it running; `POST http://localhost:9200/api/deployments` (X-Api-Key) a config adding **one** tag to a *different* equipment; assert (1) the running subscribe keeps delivering (no dead/Bad transition on the existing node), (2) the central log shows `kind=PureAdd, rebuild=False`, (3) `browse`/`read` finds the new node, (4) the new tag goes Good once the driver publishes. Phase 2: same recipe with a one-tag remove (subscription on a *different* tag survives; the removed node reads `BadNodeIdUnknown` after). Phase 3: one deploy carrying +1/−1.
+
+## Effort + Risk (per phase)
+
+| Phase | Effort | Risk / blast radius | Mitigations |
+|---|---|---|---|
+| 1 — pure-adds | **M** (~1.5-2 days incl. integration + live gate) | **High-leverage, moderate risk**: touches the apply routing core, but adds **no** sink members and no node-manager teardown; the worst regression is "should have rebuilt but didn't" (stale space) — bounded by the default-closed classifier + F10b fallback | table-driven classifier tests; recording-sink applier tests; subscription-survival integration test; mandatory live-`/run`; classifier verdict in the apply log |
+| 2 — pure-removes | **M-L** (~2-3 days) | **Highest risk slice**: new node-manager teardown under `Lock` (leaked notifier refs / map drift / lock-order inversion on the event report) + the deferred-forwarding trap | guard auto-coverage + deliberate negative control; teardown mirrors the proven `RebuildAddressSpace` loops; report-outside-Lock mirrors three existing patterns; false⇒rebuild ratchet; live-`/run` remove gate |
+| 3 — mixed | **S-M** (~0.5-1 day) | Low: composition of two shipped paths; the only new surface is remove-then-recreate ordering | targeted same-NodeId reuse test; live-`/run` mixed gate |
+
+Overall: **Large** (matches OVERALL #7), ~4-6 days. Each phase lands independently; between phases, unhandled kinds keep today's rebuild behavior.
+
+---
+
+## Task breakdown
+
+Tasks are bite-sized TDD increments. **Classification:** `high-risk` = touches the sink seam / node-manager mutation under `Lock` / apply routing (review + live-verify weight); `low-risk` = pure functions, tests, docs. Every task: run its FAIL→PASS filter, then the touched project's full suite before committing. Phase boundaries are hard sequence points (marked). Baseline branch suggestion: `feat/r2-07-surgical-pure-adds` off `master`.
+
+### ── PHASE 1 — pure-adds ──
+
+**T1 — Classifier skeleton + table-driven tests (RED→GREEN)**
+- **Classification:** low-risk (pure function)
+- **Estimated implement time:** 5 min (tests) + 5 min (impl)
+- **Parallelizable with:** T5 (different files); everything else in Phase 1 depends on it
+- **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs` (new), `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs` (new)
+- TDD: write the full `[Theory]` matrix from the Tests section (reuse `AddressSpacePlannerTests`' plan-construction helpers); `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceChangeClassifierTests"` → FAIL (type missing) → implement enum + `Classify` (first-match-wins table) with the two eligibility predicates **moved** from the applier (`AddressSpaceApplier.cs:630-663`) as `internal static`; applier temporarily references them at their new home (compile-only change to the applier) → PASS. Full project suite green.
+- Commit: `feat(opcua): add AddressSpaceChangeClassifier — typed delta classification for the applier (R2-07 T1)`
+
+**T2 — Route `Apply` through the classifier (behavior-preserving except PureAdd)**
+- **Classification:** high-risk (apply routing core)
+- **Estimated implement time:** 5 min (tests) + 10 min (impl)
+- **Parallelizable with:** — (blocks on T1)
+- **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs`
+- TDD: add applier tests — pure-add-only plan ⇒ `RebuildCalls == 0` + `RebuildCalled == false`; pure-add + surgical tag delta ⇒ no rebuild + `UpdateTagAttributes` recorded; pure-add + surgical-returns-false ⇒ rebuild fallback; remove-only / add+remove / changed-non-surgical plans ⇒ still rebuild (phase pins); `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceApplierTests"` → new tests FAIL → replace `structuralRebuild` with the classifier switch (Rebuild/AddRemoveMix/PureRemove ⇒ `SafeRebuild()`; PureAdd/AttributeOnly ⇒ surgical path) + `kind={Kind}` in the apply log → PASS, **every pre-existing applier test untouched-green** (the behavior-preservation proof).
+- Commit: `feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)`
+
+**T3 — `ComputeAddAnnouncements` + `AnnounceAddedNodes` on the applier**
+- **Classification:** low-risk (uses the existing guard-covered `RaiseNodesAddedModelChange` sink member)
+- **Estimated implement time:** 5 min (tests) + 5 min (impl)
+- **Parallelizable with:** T4a (blocks T4b)
+- **Files:** `AddressSpaceApplier.cs`, `AddressSpaceApplierTests.cs`
+- TDD: tests — two tags added under the same equipment ⇒ ONE announce for the equipment id; tag with FolderPath ⇒ announce `EquipmentNodeIds.SubFolder(...)`; added scripted alarm ⇒ equipment id; added equipment with `UnsLineId` ⇒ line id, without ⇒ its own id; empty-add plan ⇒ zero announces; filter as T2 → FAIL → implement (pure compute + Safe-wrapped sink loop) → PASS.
+- Commit: `feat(opcua): announce pure-add nodes via Part 3 NodeAdded model-change (R2-07 T3)`
+
+**T4a — Idempotent `MaterialiseAlarmCondition` (skip-if-present-and-same-kind)**
+- **Classification:** high-risk (node-manager mutation under `Lock`)
+- **Estimated implement time:** 5 min (tests) + 5 min (impl)
+- **Parallelizable with:** T3
+- **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs` (new; crib the server-bootstrap fixture from `NodeManagerModelChangeOnAddTests`)
+- TDD: tests — re-materialise same id+kind ⇒ `FindAlarmCondition` returns the **same instance** (subscription-preserving); kind-swap (isNative flip) ⇒ recreated; post-rebuild re-materialise ⇒ fresh (map was cleared); `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerAlarmIdempotentMaterialise"` FAIL → early-return guard → PASS. Run the full `NodeManager*` + `AlarmCommandRouter` suites (condition-handler regressions).
+- Commit: `feat(opcua): make MaterialiseAlarmCondition idempotent for same-kind re-applies (R2-07 T4a)`
+
+**T4b — Publish-actor post-materialise announce hook**
+- **Classification:** high-risk (deploy pipeline)
+- **Estimated implement time:** 5 min (tests) + 5 min (impl)
+- **Parallelizable with:** — (blocks on T2+T3+T4a)
+- **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs`
+- TDD: extend the rebuild tests with a pure-add artifact case — recording sink shows NO `RebuildAddressSpace`, and `RaiseNodesAddedModelChange` recorded AFTER the added tag's `EnsureVariable` (sequence list); a rebuild-kind artifact records NO announce; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~OpcUaPublishActorRebuildTests"` FAIL → add `if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);` after the fourth pass → PASS.
+- Commit: `feat(runtime): announce added nodes after the materialise passes on non-rebuild applies (R2-07 T4b)`
+
+**T5 — Subscription-survival integration test (over-the-wire)**
+- **Classification:** high-risk to write, low-risk to land (test-only)
+- **Estimated implement time:** 15 min
+- **Parallelizable with:** T1-T4 (can be authored against the design; goes green only after T4b)
+- **Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs` (new; reuse `DualEndpointTests`' server + session bootstrap)
+- TDD: subscribe tag A (real monitored item, 250 ms), pure-add apply adds tag B, `WriteValue(A)` ⇒ notification arrives (item alive), browse finds B. Written FIRST it FAILS on master behavior (rebuild kills the item); goes PASS once T2/T4b land. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests --filter "FullyQualifiedName~SubscriptionSurvivalTests"`.
+- Commit: `test(opcua): over-the-wire subscription-survival on pure-add deploy (R2-07 T5)`
+
+**T6 — Phase 1 live-`/run` gate (docker-dev) — MANDATORY before merging Phase 1**
+- **Classification:** high-risk verification (the F10b inertness net)
+- **Estimated implement time:** 20 min
+- **Parallelizable with:** —
+- **Files:** none (evidence into the PR/STATUS notes)
+- Steps: rebuild **both** central-1 + central-2 images; Client.CLI `subscribe` on an existing tag (keep running); `POST :9200/api/deployments` adding ONE tag to a different equipment; assert: subscribe stream uninterrupted; central log `kind=PureAdd, rebuild=False`; `browse`/`read` shows the new node; new tag turns Good on first driver publish. Capture the log lines.
+- Commit: `docs(archreview): record R2-07 Phase 1 live-verify evidence in STATUS.md`
+- **── PHASE 1 SHIPPABLE BOUNDARY — merge/PR here ──**
+
+### ── PHASE 2 — pure-removes ──
+
+**T7 — Sink members + deferred forwarding, guard-first (negative control observed)**
+- **Classification:** high-risk (THE deferred-sink trap; guard-set coverage FIRST)
+- **Estimated implement time:** 5 min (interface+stub, observe guard FAIL) + 5 min (forward, PASS)
+- **Parallelizable with:** — (Phase 2 root)
+- **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs`, `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`, `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs`
+- TDD (the guard IS the test): add the three members with `DeferredAddressSpaceSink` stubs `=> false` (non-forwarding); `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~DeferredSinkForwardingReflectionTests"` → **FAIL with the member-named diagnostic (negative control — record it)** → implement capability-sniffing forwards (pattern `DeferredAddressSpaceSink.cs:58-70`) → PASS. Add arg-fidelity cases to the hand-written deferred tests. NOTE: `SdkAddressSpaceSink` + `OtOpcUaNodeManager` must compile — add node-manager methods returning `false` (rebuild-fallback semantics ⇒ tree stays shippable) with xmldoc marking T8-T10 as the real implementation.
+- Commit: `feat(commons): add surgical remove members to ISurgicalAddressSpaceSink + deferred forwarding (guard-verified) (R2-07 T7)`
+
+**T8 — `RemoveVariableNode` on the node manager**
+- **Classification:** high-risk (teardown under `Lock`)
+- **Estimated implement time:** 5 min (tests) + 10 min (impl)
+- **Parallelizable with:** T9 (disjoint methods, same file — coordinate or sequence)
+- **Files:** `OtOpcUaNodeManager.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs` (new)
+- TDD: ensure→remove ⇒ `FindVariable` null + `VariableCount` down + `TryGetHistorianTagname` false; unknown id ⇒ false; `BuildNodesRemovedModelChange` carries `Verb=NodeDeleted` + `Affected` (internal seam, mirrors `NodeManagerModelChangeOnAddTests`); `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerSurgicalRemoveTests"` FAIL → implement (detach mirrors `RebuildAddressSpace:1694-1701` scoped to one id; build event under `Lock`, report outside) → PASS.
+- Commit: `feat(opcua): surgical RemoveVariableNode with NodeDeleted model-change (R2-07 T8)`
+
+**T9 — `RemoveAlarmConditionNode`**
+- **Classification:** high-risk
+- **Estimated implement time:** 5 + 5 min
+- **Parallelizable with:** T8 (coordinate on the shared test file)
+- **Files:** `OtOpcUaNodeManager.cs`, `NodeManagerSurgicalRemoveTests.cs`
+- TDD: materialise (native + scripted)→remove ⇒ `FindAlarmCondition` null + native flag cleared + `NodeDeleted` built; unknown ⇒ false; filter as T8 FAIL→PASS (teardown mirrors `:1703-1711` scoped).
+- Commit: `feat(opcua): surgical RemoveAlarmConditionNode (R2-07 T9)`
+
+**T10 — `RemoveEquipmentSubtree` (descendants + notifier demotion)**
+- **Classification:** high-risk (the notifier/root-ref leak hazard)
+- **Estimated implement time:** 10 min (tests) + 15 min (impl)
+- **Parallelizable with:** — (blocks on T8+T9 for the per-node primitives it reuses)
+- **Files:** `OtOpcUaNodeManager.cs`, `SdkAddressSpaceSink.cs` (the three forwards), `NodeManagerSurgicalRemoveTests.cs`
+- TDD: equipment with subfolder+variables+condition → remove ⇒ all descendants gone from every map, `RemoveRootNotifier` invoked (no orphaned Server↔folder ref — assert via `_notifierFolders`-driven observable or the notifier accessor), `_eventNotifierSources` entry dropped, sibling equipment fully intact; unknown ⇒ false; filter FAIL→PASS. Wire the three `SdkAddressSpaceSink` forwards here (replacing T7's false-stubs at the manager) and re-run the Commons guard.
+- Commit: `feat(opcua): surgical RemoveEquipmentSubtree with notifier demotion (R2-07 T10)`
+
+**T11 — Applier PureRemove arm (terminal writes + subsumption + fallback ratchet)**
+- **Classification:** high-risk (apply routing)
+- **Estimated implement time:** 10 min (tests) + 10 min (impl)
+- **Parallelizable with:** —
+- **Files:** `AddressSpaceApplier.cs`, `AddressSpaceApplierTests.cs`
+- TDD: remove-one-tag plan ⇒ Bad `WriteValue` then `RemoveVariableNode`, NO rebuild; removed alarm-bearing tag ⇒ `RemovedConditionState` write + `RemoveAlarmConditionNode` (closes the today-gap); removed equipment ⇒ `RemoveEquipmentSubtree` and NO individual child removals (subsumption); any remove returns false ⇒ `SafeRebuild()` + no further surgical calls (ratchet); flip the T2 phase-pin ("remove-only ⇒ rebuild") to the new expectation; filter `FullyQualifiedName~AddressSpaceApplierTests` FAIL→PASS.
+- Commit: `feat(opcua): surgical pure-remove deploys — scoped teardown, no full rebuild (R2-07 T11)`
+
+**T12 — Phase 2 integration + live gate**
+- **Classification:** high-risk verification
+- **Estimated implement time:** 15 min (test) + 20 min (live)
+- **Parallelizable with:** —
+- **Files:** `SubscriptionSurvivalTests.cs`
+- TDD: subscribe A+B → surgical-remove B ⇒ A's item alive; B's item observes final Bad; B unbrowsable (`BadNodeIdUnknown` on read). Then the live-`/run` remove recipe (both centrals; subscribe survivor; deploy −1 tag; log shows `kind=PureRemove, rebuild=False`). Record evidence in STATUS.md.
+- Commit: `test(opcua): subscription-survival on pure-remove + live-verify evidence (R2-07 T12)`
+- **── PHASE 2 SHIPPABLE BOUNDARY ──**
+
+### ── PHASE 3 — mixed batches ──
+
+**T13 — Classifier + applier `AddRemoveMix` routing (removes-then-adds)**
+- **Classification:** high-risk (apply routing)
+- **Estimated implement time:** 5 min (tests) + 5 min (impl)
+- **Parallelizable with:** —
+- **Files:** `AddressSpaceApplier.cs`, `AddressSpaceApplierTests.cs`, `AddressSpaceChangeClassifierTests.cs`
+- TDD: add+remove plan ⇒ removes applied surgically, NO rebuild, `RebuildCalled=false` (flip the T2 phase pin); remove-then-recreate same folder-scoped NodeId in one plan ⇒ remove recorded before the passes would re-ensure (sequence assertion); any remove failure ⇒ rebuild ratchet still holds; filters as before FAIL→PASS.
+- Commit: `feat(opcua): surgical mixed add+remove deploys (R2-07 T13)`
+
+**T14 — Phase 3 integration + live gate + docs close-out**
+- **Classification:** high-risk verification + low-risk docs
+- **Estimated implement time:** 15 min (test) + 20 min (live) + 10 min (docs)
+- **Parallelizable with:** —
+- **Files:** `SubscriptionSurvivalTests.cs`, `archreview/plans/STATUS.md`, `CLAUDE.md`/`docs` note if the deploy-behavior description warrants it, `archreview/03-server-runtime.md` P1 row (mark remediated)
+- TDD: mixed-deploy integration case (subscribe A; deploy −B/+C; A alive, B gone, C browsable). Live: one deploy carrying +1/−1 on docker-dev (both centrals), log `kind=AddRemoveMix, rebuild=False`, survivor subscription uninterrupted. Update STATUS.md + the P1 finding row.
+- Commit: `test+docs: mixed-deploy survival, live evidence, STATUS/P1 close-out (R2-07 T14)`
+- **── PHASE 3 / PLAN COMPLETE ──**
diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json
new file mode 100644
index 00000000..27f9622c
--- /dev/null
+++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json
@@ -0,0 +1,96 @@
+{
+  "planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    {
+      "id": "T1",
+      "subject": "Phase 1: AddressSpaceChangeClassifier + table-driven tests (move TagDeltaIsSurgicalEligible/VtagDeltaIsNodeIrrelevant)",
+      "status": "pending",
+      "blockedBy": []
+    },
+    {
+      "id": "T2",
+      "subject": "Phase 1: route AddressSpaceApplier.Apply through the classifier — PureAdd skips rebuild (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T1"]
+    },
+    {
+      "id": "T3",
+      "subject": "Phase 1: ComputeAddAnnouncements + AnnounceAddedNodes on the applier (dedup parent NodeAdded announces)",
+      "status": "pending",
+      "blockedBy": ["T1"]
+    },
+    {
+      "id": "T4a",
+      "subject": "Phase 1: idempotent MaterialiseAlarmCondition (skip-if-present-and-same-kind) (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T1"]
+    },
+    {
+      "id": "T4b",
+      "subject": "Phase 1: OpcUaPublishActor calls AnnounceAddedNodes after the Materialise passes on non-rebuild applies (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T2", "T3", "T4a"]
+    },
+    {
+      "id": "T5",
+      "subject": "Phase 1: over-the-wire SubscriptionSurvivalTests — subscribe, pure-add, item survives + new node browsable",
+      "status": "pending",
+      "blockedBy": ["T4b"]
+    },
+    {
+      "id": "T6",
+      "subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) — Phase 1 shippable boundary",
+      "status": "pending",
+      "blockedBy": ["T5"]
+    },
+    {
+      "id": "T7",
+      "subject": "Phase 2: add RemoveVariableNode/RemoveAlarmConditionNode/RemoveEquipmentSubtree to ISurgicalAddressSpaceSink + DeferredAddressSpaceSink forwarding — guard-first with observed negative control (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T6"]
+    },
+    {
+      "id": "T8",
+      "subject": "Phase 2: OtOpcUaNodeManager.RemoveVariableNode + BuildNodesRemovedModelChange (NodeDeleted) (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T7"]
+    },
+    {
+      "id": "T9",
+      "subject": "Phase 2: OtOpcUaNodeManager.RemoveAlarmConditionNode (+ native-flag cleanup) (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T7"]
+    },
+    {
+      "id": "T10",
+      "subject": "Phase 2: OtOpcUaNodeManager.RemoveEquipmentSubtree (descendants + notifier demotion) + SdkAddressSpaceSink forwards (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T8", "T9"]
+    },
+    {
+      "id": "T11",
+      "subject": "Phase 2: applier PureRemove arm — terminal Bad/RemovedConditionState writes, equipment subsumption, false-to-rebuild ratchet (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T10"]
+    },
+    {
+      "id": "T12",
+      "subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) — Phase 2 shippable boundary",
+      "status": "pending",
+      "blockedBy": ["T11"]
+    },
+    {
+      "id": "T13",
+      "subject": "Phase 3: AddRemoveMix routing — removes-then-adds surgically, same-NodeId remove-then-recreate ordering test (high-risk)",
+      "status": "pending",
+      "blockedBy": ["T12"]
+    },
+    {
+      "id": "T14",
+      "subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out — plan complete",
+      "status": "pending",
+      "blockedBy": ["T13"]
+    }
+  ]
+}
diff --git a/archreview/plans/R2-08-ldap-historyread-async-plan.md b/archreview/plans/R2-08-ldap-historyread-async-plan.md
new file mode 100644
index 00000000..9c1b477a
--- /dev/null
+++ b/archreview/plans/R2-08-ldap-historyread-async-plan.md
@@ -0,0 +1,676 @@
+# Design + Implementation Plan — R2-08: Async LDAP authentication + HistoryRead bridging
+
+- **Source report:** `archreview/03-server-runtime.md` (2026-07-12 re-review, working-tree version)
+- **Review commit:** `f6eaa267` · **Plan verified against tree at:** `f6eaa267` (master, clean)
+- **Scope:** OVERALL prioritized action item **#8** — "Async LDAP + configurable timeout; channel-ize
+  HistoryRead bridging" — covering findings **03/S2** (High — LDAP authentication block-bridges OPC UA
+  session activation with an unconfigurable ~10 s timeout, no pooling, no fail-fast) and **03/S3**
+  (High — HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads,
+  bounded only by the 30 s gateway `CallTimeout`). Both were carried unfixed from round 1
+  (`archreview/plans/03-server-runtime-plan.md` §S2/§S3 — designs verified + refreshed below).
+- **Touches:** `Security/Ldap/LdapOptions.cs`, `Security/Ldap/OtOpcUaLdapAuthService.cs`,
+  `Security/Ldap/LdapAuthResult.cs`, `Host/OpcUa/LdapOpcUaUserAuthenticator.cs`,
+  `Host/Configuration/LdapOptionsValidator.cs`, `OpcUaServer/OpcUaApplicationHost.cs` (doc only),
+  `OpcUaServer/OtOpcUaNodeManager.cs`, `Runtime/Historian/ServerHistorianOptions.cs`,
+  `Host/OpcUa/OtOpcUaServerHostedService.cs`, plus tests in `Host.IntegrationTests`,
+  `Security.Tests`, `Runtime.Tests`, `OpcUaServer.Tests`. **No shared-lib (`ZB.MOM.WW.Auth`) change
+  required** — see Verification.
+
+## Verification summary
+
+Every anchor in both findings was opened against the working tree at `f6eaa267`; the round-1 plan's
+S2/S3 designs were re-verified against the code and the actual SDK + shared-library sources. **Both
+findings CONFIRMED — and S2 is materially *worse* than the report states.** Details:
+
+- **S2 anchors — confirmed, one path drift.** The report cites
+  `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaApplicationHost.cs:271-274`; the file actually lives at
+  `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` (the authenticator *is* under
+  `Host/OpcUa/`). The block-bridge is exact: `HandleImpersonation` calls
+  `authenticator.AuthenticateUserNameAsync(…, CancellationToken.None).GetAwaiter().GetResult()`
+  (`OpcUaApplicationHost.cs:270-273`). `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync`
+  (`Host/OpcUa/LdapOpcUaUserAuthenticator.cs:30-48`) adds no timeout, no concurrency bound, no
+  fail-fast. `LdapOptions.ToLibraryOptions()` (`Security/Ldap/LdapOptions.cs:94-107`) projects no
+  timeout field and `LdapOptions` has none.
+- **NEW (verified from SDK source, worse than reported): the impersonation callback runs inside a
+  global event lock.** Verified against the pinned `OPCFoundation.NetStandard.Opc.Ua.Server`
+  **1.5.378.106** sources (`Libraries/Opc.Ua.Server/Session/SessionManager.cs`):
+  `ActivateSessionAsync` raises the handler as `lock (m_eventLock) { … m_ImpersonateUser(session, args); … }`
+  (lines 352-360; `m_eventLock` is a single `SessionManager`-wide `Lock`, line 698). The handler
+  signature is a synchronous `void` delegate; there is **no async variant** of the impersonation
+  callback (the surrounding `ActivateSessionAsync` is `ValueTask`-returning, but the callback itself
+  must complete inline). Consequence: one stalled LDAP bind does not just hold *one* SDK thread — it
+  **serializes every session activation server-wide** (Anonymous and X509 included, since even the
+  early-return path must acquire `m_eventLock` to invoke the handler). The SDK contract is therefore
+  irreducibly sync → the fix is a hard wall-clock bound + bounded concurrency + outage fail-fast at
+  the authenticator boundary, exactly the shape the report recommends. No "fake async" is attempted.
+- **NEW (resolves a round-1 open choice): the shared library already exposes the timeout.**
+  `ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.ConnectionTimeoutMs` exists (default `10_000`,
+  `LdapContracts.cs:20` in `~/Desktop/scadaproj/ZB.MOM.WW.Auth`), and the Novell adapter applies it to
+  both the socket connect (`_conn.ConnectionTimeout`) and the per-operation search constraint
+  (`constraints.TimeLimit`) (`NovellLdapConnection.ApplyTimeout`, lines 131-146). The round-1 plan's
+  step 1 said "do **not** project into `ToLibraryOptions()` … unless the library later exposes a
+  matching field" — **the field exists; this plan projects it.** Also verified: the library's
+  `AuthenticateAsync` is `Task.FromResult(Authenticate(...))` — synchronous-in-Task, token observed at
+  entry only (`LdapAuthService.cs:59-66`), and each call is a fresh connect + service-bind + search +
+  user-bind (no pooling; pooling stays a shared-lib follow-up, out of scope here). The library is
+  fully fail-closed (never throws; system-side faults map to `LdapAuthFailure.ServiceAccountBindFailed`).
+- **S3 anchors — all four bridge sites line-exact** at `f6eaa267`:
+  `OtOpcUaNodeManager.cs:1911` (Events), `:2059` (`ServeNode` — serves Processed + AtTime), `:2171`
+  (Raw page read), `:2203` (Raw tie-cluster over-fetch) — each `.GetAwaiter().GetResult()` with
+  `CancellationToken.None`. The four arms iterate `nodesToProcess` in a sequential `foreach`
+  (`:1784` RawModified, `:1811` Processed, `:1851` AtTime, `:1873` Events). SDK thread budget
+  confirmed: `MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200`
+  (`OpcUaServer/OpcUaApplicationHost.cs:333-335`). The overrides run **outside** the node-manager
+  `Lock` (documented at `:1777-1780`) — this is a request-pool-exhaustion risk, not a lock-freeze risk.
+- **S3 thread-safety audit (round-1 plan asked for it — done):** parallel per-node serving is safe.
+  `results[handle.Index]` / `errors[handle.Index]` writes are index-disjoint into pre-sized lists (the
+  base dispatcher seeds every slot). All shared node-manager maps the read path touches are
+  `ConcurrentDictionary` (`_variables:41`, `_historizedTagnames:55`, `_eventNotifierSources:69`).
+  `IHistoryContinuationStore` is explicitly thread-safe (SDK per-session store — "the SDK session
+  locks internally", `HistoryContinuationStore.cs`; the test store is a lock-protected dictionary).
+  `ServeRawPaged` state is all per-handle locals. `IHistorianDataSource` (the gateway client) is a
+  gRPC channel wrapper — safe for concurrent calls; each call already carries the 30 s `CallTimeout`
+  deadline (`ServerHistorianOptions.CallTimeout`, `ServerHistorianOptions.cs:58`).
+- **Round-1 plan §S3 design re-verified:** bounded per-node parallelism + per-request deadline +
+  server-side limiter all still fit the tree; refined below (OCE→`BadTimeout` per node, limiter wait
+  budget, lazy gate creation, options names) and the "fully async HistoryRead" alternative re-confirmed
+  impossible — `CustomNodeManager2`'s `HistoryRead*` overrides are synchronous `void` fillers.
+- **Existing test harnesses located** (reused, not invented): `Host.IntegrationTests/
+  LdapOpcUaUserAuthenticatorTests.cs` (offline `FakeLdap : ILdapAuthService` + `FakeMapper` — the exact
+  seam the prompt's stall-repro needs), `LdapOptionsBindingTests.cs`, `LdapOptionsValidatorTests.cs`,
+  `Security.Tests/OtOpcUaLdapAuthServiceTests.cs`, `OpcUaServer.Tests/NodeManagerHistoryReadTests.cs`
+  (boots the real SDK server in-process, invokes the public `HistoryRead` → real base dispatch → our
+  arms, with a `RecordingHistorianDataSource` fake) and `NodeManagerHistoryReadPagingTests.cs`,
+  `Runtime.Tests/Historian/AddServerHistorianTests.cs`. Dev/test LDAP facts confirmed: shared GLAuth
+  `10.100.0.35:3893` (bind `cn=serviceaccount,dc=zb,dc=local`) for manual verification; the
+  `Host.IntegrationTests` harness uses ephemeral bitnami/openldap on `:3894` for automated runs — the
+  outage sim below needs **neither** (an unroutable IP reproduces the outage shape offline).
+
+---
+
+## S2 — HIGH — LDAP authentication block-bridges session activation with a non-cancellable, non-configurable timeout
+
+**Restatement:** The SDK invokes the impersonation callback synchronously (inside a `SessionManager`-wide
+event lock — see Verification); `HandleImpersonation` block-bridges the authenticator with
+`CancellationToken.None`; no layer enforces a timeout; the shared library's 10 s connect timeout is a
+non-projected default; every authentication is a fresh connect + service-bind + search + user-bind.
+Under a directory outage, session activations stall **serially, server-wide**, ~10-20 s each.
+
+**Verification:** Confirmed (all anchors above). The event-lock serialization makes this strictly worse
+than the report's "stalls SDK threads serially": Anonymous/X509 activations queue behind a stalled
+UserName bind too.
+
+**Root cause:** `HandleImpersonation`'s doc comment delegates timeout responsibility to the
+authenticator, but `LdapOpcUaUserAuthenticator` never took it up; and OtOpcUa's `LdapOptions` never
+projected the library's existing `ConnectionTimeoutMs`, so the 10 s library default is both invisible
+and unconfigurable. Nothing bounds concurrent in-flight binds or fails fast during a sustained outage.
+
+**Proposed design — hard wall-clock bound + bounded concurrency + outage circuit, all inside
+`LdapOpcUaUserAuthenticator`; project the library timeout.** The SDK callback is irreducibly sync
+(verified), so the fix is entirely at the OtOpcUa authenticator boundary:
+
+1. **Project the library timeout** — add `ConnectionTimeoutMs` (default `10000`, matching the library
+   default so behavior is unchanged unless configured) to OtOpcUa's `LdapOptions` and project it in
+   `ToLibraryOptions()`. This bounds the socket connect and each search op inside the library.
+2. **Hard whole-flow timeout at the boundary** — `AuthenticateUserNameAsync` wraps its existing body
+   (bind **and** role-map — one budget for everything between SDK-callback entry and result) in
+   `core.WaitAsync(TimeSpan.FromMilliseconds(AuthTimeoutMs), ct)`; `TimeoutException` ⇒
+   `Deny("Authentication timed out")` (fail-closed) + Warning log. Default `AuthTimeoutMs = 15000` —
+   deliberately > `ConnectionTimeoutMs` so a healthy-but-slow full flow (connect + 2 binds + search,
+   each individually under 10 s) isn't cut off by the boundary, while an outage (connect stall) is
+   bounded by the library timeout first and the boundary is the backstop. Note the abandoned inner
+   task runs to completion in the background — the library is documented never-throw, so no unobserved
+   exceptions; the point is releasing the SDK thread (and `m_eventLock`).
+3. **Bounded in-flight concurrency** — a `SemaphoreSlim(MaxConcurrentAuthentications)` acquired before
+   starting the core task and released by a continuation **when the core task completes** (not in the
+   caller's `finally`) — so abandoned/orphaned binds count against the cap and an outage can never
+   accumulate unbounded orphan tasks. A caller that cannot acquire a slot within its own budget denies
+   with `"Authentication backend busy"` (does not count toward the circuit — local backpressure is not
+   directory evidence). With the SDK's event-lock serialization only one *SDK* thread is ever in the
+   callback at a time, but the cap still bounds orphan accumulation across successive timed-out calls.
+4. **Directory-outage circuit (fail-fast)** — after `OutageFailureThreshold` (default 3) *consecutive
+   system-side* failures (boundary timeout, `IsSystemFailure` result, or unexpected throw), the
+   authenticator denies instantly (`"Authentication backend unavailable"`) for
+   `OutageCooldownSeconds` (default 15) without touching LDAP; after cooldown the next attempt probes
+   (half-open — the counter stays at threshold so one more system failure re-opens immediately). Any
+   success or *user-side* deny resets the counter. Distinguishing system-side from user-side needs a
+   structured signal: add `bool IsSystemFailure { get; init; }` to the app `LdapAuthResult`
+   (`Security/Ldap/LdapAuthResult.cs` — additive init property, no call-site churn), set by
+   `OtOpcUaLdapAuthService.Adapt` for `LdapAuthFailure.ServiceAccountBindFailed` (the library's
+   directory-unreachable bucket — its enum has no dedicated `DirectoryUnavailable`; documented
+   limitation, `LdapAuthService.cs:94-96`).
+5. **Negative cache — REJECTED** (resolves the round-1 "consider a short negative cache" and the
+   security caution): a per-credential deny cache requires holding password digests in memory, creates
+   a window where a just-corrected password is still rejected, and adds a cache-timing side channel —
+   for near-zero benefit, because a bad-credential bind is *fast* when the directory is up; the
+   expensive stall only exists when the directory is *down*, which the credential-blind outage circuit
+   covers strictly better.
+
+**Alternatives considered:**
+- *Make the shared library cooperatively cancellable / pooled* — cross-repo change to
+  `ZB.MOM.WW.Auth` (Novell calls are sync-blocking; pooling changes the security posture of bind
+  reuse). Track as a shared-lib follow-up; not blocking — the boundary bound is sufficient here.
+- *A `ResilientOpcUaUserAuthenticator` decorator over `IOpcUaUserAuthenticator`* — cleaner layering on
+  paper, but it would need the system-vs-user failure signal widened onto `OpcUaUserAuthResult`
+  (an OpcUaServer-project contract) and a second options plumbing path; the inline fix keeps the
+  resilience next to the only LDAP-backed implementation and the signal on the app-owned
+  `LdapAuthResult`. Rejected for scope.
+- *Pass a real timeout-derived token from `HandleImpersonation`* — the library observes the token at
+  entry only (verified), so it cannot shorten anything the boundary `WaitAsync` doesn't already bound.
+  Not done; `HandleImpersonation` keeps `CancellationToken.None` and gains a doc comment stating the
+  verified SDK contract (sync callback under `m_eventLock`) and where the bound lives.
+- *Fully async activation* — impossible; no async impersonation callback exists in 1.5.378 (verified).
+
+**Implementation steps** (task-level detail in the breakdown):
+1. `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` — add five keys (defaults):
+   `ConnectionTimeoutMs = 10000` (projected into `ToLibraryOptions()`), `AuthTimeoutMs = 15000`,
+   `MaxConcurrentAuthentications = 8`, `OutageFailureThreshold = 3` (`0` disables the circuit),
+   `OutageCooldownSeconds = 15`.
+2. `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs` — add
+   `public bool IsSystemFailure { get; init; }`.
+3. `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs` — `Adapt` sets
+   `IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed` on the failure path.
+4. `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs` — take
+   `IOptions`; move the existing body to a private `AuthenticateCoreAsync`; add the
+   circuit check → semaphore acquire (release-on-core-completion continuation) → `WaitAsync` timeout →
+   circuit bookkeeping, per the design. DI registration (`Host/Program.cs:249`) is unchanged.
+5. `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs` — when
+   `Enabled && !DevStubMode`: `ConnectionTimeoutMs > 0`, `AuthTimeoutMs > 0`,
+   `MaxConcurrentAuthentications > 0`, and `OutageCooldownSeconds > 0` when
+   `OutageFailureThreshold > 0`.
+6. `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs` — doc-comment only on
+   `HandleImpersonation`: record the verified SDK contract (sync `void` handler raised under
+   `SessionManager.m_eventLock` in 1.5.378 — activations serialize) and that the wall-clock bound is
+   the authenticator's responsibility (now actually enforced).
+7. `docs/security.md` — document the five new `Security:Ldap` keys + fail-fast semantics.
+
+**Tests.**
+- **Stall-repro (RED first, no real LDAP):** `Host.IntegrationTests` — new
+  `LdapAuthResilienceTests` beside the existing suite, reusing its `FakeLdap : ILdapAuthService`
+  pattern with a delaying handler. `Authenticate_slow_backend_denies_within_AuthTimeout`
+  (fake delays 5 s, `AuthTimeoutMs = 200` → assert Deny + elapsed < ~1.5 s) — **fails on current
+  code** (returns Allow after the full 5 s).
+- **Unit:** fast success unaffected; timeout denial is fail-closed + logged; circuit opens after N
+  consecutive system failures and denies without invoking the fake (call-count pinned); user-side deny
+  does *not* trip the circuit; half-open probe after cooldown; success resets; semaphore denies
+  "busy" when saturated; `OtOpcUaLdapAuthServiceTests` — `Adapt` maps `ServiceAccountBindFailed` →
+  `IsSystemFailure = true`, `BadCredentials` → `false`; `LdapOptionsBindingTests` /
+  `LdapOptionsValidatorTests` extended for the new keys. All six existing
+  `LdapOpcUaUserAuthenticatorTests` stay green (ctor gains options — update construction).
+- **Outage-sim integration (offline-safe):** real `OtOpcUaLdapAuthService` + real library pointed at an
+  unroutable host (`10.255.255.1:3893` — TCP SYN blackhole, the exact shape of "stop the GLAuth
+  container"), small `ConnectionTimeoutMs`/`AuthTimeoutMs`, driven through the *real*
+  `HandleImpersonation` (it is `internal static` — invokable without booting the SDK, precedent
+  `OpcUaApplicationHostImpersonationTests`): assert bounded fail + `BadIdentityTokenRejected`.
+- **Live/manual (post-merge, not gating):** docker-dev rig — point `Security:Ldap:Server` at an
+  unreachable host, drive `Client.CLI connect -u opc.tcp://localhost:4840` with a UserName token
+  (e.g. the `multi-role` GLAuth user), confirm fast bounded denial and that a burst of activations
+  doesn't serialize for 10 s each; then restore `10.100.0.35:3893` and confirm normal login.
+
+**Effort:** M. **Risk/blast-radius:** Low-Medium. All defaults chosen so behavior is unchanged on a
+healthy directory (`ConnectionTimeoutMs` 10 s = current library default; `AuthTimeoutMs` 15 s only cuts
+flows that today stall past 15 s; the circuit requires 3 *consecutive system* failures). Fail-closed
+posture preserved end-to-end (every new path denies). The AdminUI login path
+(`OtOpcUaLdapAuthService` direct) is untouched except the additive `IsSystemFailure` flag.
+
+---
+
+## S3 — HIGH — HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads
+
+**Restatement:** All four HistoryRead arms serve nodes sequentially, each node a
+`.GetAwaiter().GetResult()` gateway call with `CancellationToken.None`, bounded only by the 30 s
+per-call gateway `CallTimeout`. One request naming N historized nodes against a slow historian holds
+one SDK request thread up to N × 30 s against `MaxRequestThreadCount = 100` /
+`MaxQueuedRequestCount = 200`; a handful of misbehaving history clients can exhaust the pool and
+degrade every OPC UA service.
+
+**Verification:** Confirmed — bridge sites `OtOpcUaNodeManager.cs:1911, 2059, 2171, 2203`; sequential
+`foreach` in all four arms; thread-safety audit for parallelization passed (see Verification summary).
+`GatewayHistorianDataSource` is already fully async underneath with a per-call deadline — **the
+bridging pattern is the problem, not the data source.**
+
+**Root cause:** Per-node reads are serialized with no per-request deadline and no server-side cap on
+concurrent HistoryRead work; the block-bridge is structural (the `CustomNodeManager2.HistoryRead*`
+override surface is synchronous `void` — verified), but nothing says it must be *per-node* and
+*unbounded*.
+
+**Proposed design — bounded per-node fan-out + per-request deadline + process-wide batch limiter,
+one block-bridge per arm.** Three composable bounds, each configurable under `ServerHistorian`:
+
+1. **Bounded per-node parallelism within a batch.** Async-ify the per-node serving internals
+   (`ServeNode` → `ServeNodeAsync`, `ServeRawPaged` → `ServeRawPagedAsync`, the Events body →
+   `ServeEventsAsync`) — each stays fully self-contained try/catch (per-node error isolation
+   unchanged). Each arm collects one `Func` per handle and runs them through a shared
+   `RunBounded(work, degree)` helper: a `SemaphoreSlim(degree)`-gated `Task.WhenAll`, block-bridged
+   **once** at the arm boundary (`Task.WhenAll(...).GetAwaiter().GetResult()` — safe: bodies never
+   throw). Worst case drops from N × 30 s to ~⌈N/P⌉ × 30 s. Degree:
+   `HistoryReadBatchParallelism`, default **4** (bounds the gateway-load multiplication a single
+   client can command — see Risk).
+2. **Per-request deadline.** Each arm creates `using var cts = new CancellationTokenSource(HistoryReadDeadline)`
+   (default **60 s**) and threads `cts.Token` into every data-source call (replacing
+   `CancellationToken.None` at all four sites). Per-node handlers gain a
+   `catch (OperationCanceledException)` arm mapping to **`BadTimeout`** for that node (before the
+   generic catch, which keeps mapping other failures to `BadHistoryOperationUnsupported` as today).
+   A single request can no longer hold a thread longer than the deadline regardless of node count.
+3. **Process-wide concurrent-batch limiter.** A node-manager `SemaphoreSlim(MaxConcurrentHistoryReadBatches)`
+   (default **16**, created lazily on first HistoryRead so the post-boot property set is respected —
+   same benign wiring race as the existing `MaxTieClusterOverfetch` property) gates each arm's body.
+   Entry waits up to `min(HistoryReadDeadline, 5 s)`; on expiry every handle in the batch gets
+   **`BadTooManyOperations`** (fail-fast under flood, brief waits under mild contention — resolves the
+   round-1 "wait *or* fail" open choice with both, bounded). 16 batches × degree 4 also caps total
+   in-flight gateway calls at 64.
+
+**Alternatives considered:**
+- *Fully async HistoryRead* — the SDK override surface is synchronous `void` (verified); structural.
+  Rejected; parallelize within the sync boundary.
+- *`Parallel.ForEachAsync`* for the fan-out — throws `OperationCanceledException` at the *scheduling*
+  level on token cancellation, which would bypass per-node error isolation and throw out of the arm.
+  The `SemaphoreSlim` + `Task.WhenAll` shape keeps "bodies never throw" airtight. Rejected.
+- *Rejecting multi-node history requests* — breaks legitimate clients. Rejected (round-1 concurrence).
+- *A channel/queue + dedicated reader pool* — heavier machinery for the same bound; the semaphore
+  composition is sufficient and keeps the diff reviewable. Rejected.
+- *Per-node continuation-point-aware sub-paging changes* — none needed: paging state
+  (`ServeRawPaged`/continuation store) is per-handle and thread-safe (audit above);
+  `TimestampsToReturn` handling is untouched (the arms already receive it and the projection helpers
+  are pure).
+
+**Implementation steps** (task-level detail in the breakdown):
+1. `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs` — add
+   `HistoryReadBatchParallelism = 4`, `MaxConcurrentHistoryReadBatches = 16`,
+   `HistoryReadDeadline = TimeSpan.FromSeconds(60)`; `Validate()` warns on non-positive values
+   (Enabled-gated, same pattern as `MaxTieClusterOverfetch`).
+2. `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — three settable properties
+   mirroring the `MaxTieClusterOverfetch` precedent (`HistoryReadBatchParallelism`,
+   `MaxConcurrentHistoryReadBatches`, `HistoryReadDeadline`); async-ified per-node internals with
+   `ct` parameters; `RunBounded` helper; lazily-created batch-limiter gate; the four arms rewritten to
+   limiter → deadline CTS → work-item collection → bounded fan-out → single bridge.
+3. `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs` — set the three node-manager
+   properties beside the existing `MaxTieClusterOverfetch` assignment (`StartAsync`).
+4. `docs/Historian.md` + the CLAUDE.md `ServerHistorian` key table — document the three new keys.
+
+**Tests.**
+- **Stall-repro (RED first, no real gateway):** `OpcUaServer.Tests` — new
+  `NodeManagerHistoryReadConcurrencyTests` reusing the `NodeManagerHistoryReadTests` boot harness with
+  a delaying fake `IHistorianDataSource` that tracks max observed concurrent reads (interlocked).
+  `Raw_batch_is_served_with_bounded_parallelism`: 8 historized nodes, 150 ms delay per read → assert
+  `maxConcurrent >= 2` (deterministic — **fails on current sequential code** where it is pinned at 1)
+  + a loose wall-clock secondary bound (< ~⌈8/4⌉ × 150 ms + slack).
+- **Unit:** per-node isolation regression under parallelism (one node's fake throws → that node Bad,
+  others Good — the existing guarantee, re-proven concurrent); parallelism also bounded above
+  (`maxConcurrent <= degree`); deadline — a fake whose task only completes on token cancellation →
+  every node `BadTimeout` within deadline + slack, call returns (fails-by-hanging is prevented by
+  wrapping the invoke in `Task.Run(...).WaitAsync(10 s)`); limiter — `MaxConcurrentHistoryReadBatches=1`,
+  first batch parked in the fake, second concurrent `HistoryRead` (small `HistoryReadDeadline` so the
+  wait budget is short) → all its handles `BadTooManyOperations`; existing
+  `NodeManagerHistoryReadTests` + `NodeManagerHistoryReadPagingTests` (incl. tie-cluster paths) stay
+  green — the paging suite is the continuation-point-correctness regression net;
+  `AddServerHistorianTests` / options tests extended for the new keys.
+- **Integration/live (post-merge, not gating):** the env-gated `Category=LiveIntegration` gateway suite
+  (VPN + real HistorianGateway) re-run; optionally a manual soak — one client issuing a 50-node Raw
+  HistoryRead against a slow historian while a second client browses/subscribes, confirming the second
+  client stays responsive (the pool-exhaustion scenario).
+
+**Effort:** M. **Risk/blast-radius:** Medium. Parallelizing multiplies peak gateway load by up to
+`HistoryReadBatchParallelism` per request (default 4, total in-flight capped at 64 by the limiter —
+the knobs exist precisely to tune this against a weak historian). Result/error index writes verified
+disjoint; all touched shared state verified concurrent-safe. Client-visible behavior changes only in
+failure shapes (`BadTimeout` on deadline, `BadTooManyOperations` under flood — both standard,
+spec-conformant codes) and latency (better). Continuation-point semantics untouched.
+
+---
+
+## Task breakdown
+
+Branch: `fix/archreview-r2-08-ldap-historyread-async` off `master@f6eaa267`. Tasks are ≤5 min each;
+TDD-ordered (each RED repro precedes its implementation). Test commands run from the repo root; all
+automated tests are offline-safe (no Docker, no VPN, no real LDAP/gateway).
+
+---
+
+### R2-08-T1 — S2 stall-repro test (RED — must fail on current code)
+
+**Classification:** test-first repro (deterministic unit, fake `ILdapAuthService` with a delay)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7, T8 (different files/subsystem).
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs` (new)
+
+1. New test class beside `LdapOpcUaUserAuthenticatorTests`, cloning its `FakeLdap`/`FakeMapper`
+   pattern but with an async handler seam: `DelayingFakeLdap(TimeSpan delay, LdapAuthResult then)`
+   (`await Task.Delay(delay, ct)` before returning) and an `Interlocked` call counter.
+2. Add `Authenticate_slow_backend_denies_within_AuthTimeout`: construct `LdapOpcUaUserAuthenticator`
+   exactly as the existing tests do (this is RED partly *because* there is no way to pass a timeout
+   yet — write the test against the intended ctor shape
+   `new LdapOpcUaUserAuthenticator(ldap, scopeFactory, Options.Create(new LdapOptions { AuthTimeoutMs = 200 }), logger)`;
+   until T4 lands the file won't compile — acceptable for a same-branch RED, or temporarily pin with
+   the current ctor + assert elapsed, which fails by timing). Fake delays 5 s; assert
+   `result.Success == false`, `result.Error` contains "timed out", and a `Stopwatch` elapsed < 1.5 s.
+   Wrap the await in `.WaitAsync(TimeSpan.FromSeconds(10))` so a regression fails rather than hangs.
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests.Authenticate_slow_backend_denies_within_AuthTimeout"`
+   → **expected: FAIL** (compile-fail against intended ctor, or Allow-after-5s timing fail).
+
+Commit: `test(host): red repro — LDAP authenticator has no wall-clock bound on session activation (03/S2)`
+
+---
+
+### R2-08-T2 — LdapOptions keys + library projection + validator
+
+**Classification:** implementation (options + validation, additive)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T3, T7, T8.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`,
+`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs`
+
+1. RED: extend `LdapOptionsBindingTests` — a config section carrying the five new keys binds them;
+   defaults hold when absent; `ToLibraryOptions().ConnectionTimeoutMs` mirrors the app value. Extend
+   `LdapOptionsValidatorTests` — non-positive `ConnectionTimeoutMs`/`AuthTimeoutMs`/
+   `MaxConcurrentAuthentications` fail validation when `Enabled && !DevStubMode`;
+   `OutageFailureThreshold = 3` with `OutageCooldownSeconds = 0` fails; `OutageFailureThreshold = 0`
+   with any cooldown passes; disabled/DevStub configs stay exempt.
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapOptionsBindingTests|FullyQualifiedName~LdapOptionsValidatorTests"`
+   → **expected: FAIL** (properties don't exist).
+2. GREEN: add `ConnectionTimeoutMs = 10000`, `AuthTimeoutMs = 15000`,
+   `MaxConcurrentAuthentications = 8`, `OutageFailureThreshold = 3`, `OutageCooldownSeconds = 15`
+   (XML-doc each: what it bounds, why the default, `0` disables the circuit); project
+   `ConnectionTimeoutMs` in `ToLibraryOptions()`; add the validator rules. Re-run the filter → **PASS**.
+
+Commit: `feat(security): LDAP auth timeout/concurrency/outage-circuit options; project ConnectionTimeoutMs into the shared library (03/S2)`
+
+---
+
+### R2-08-T3 — `LdapAuthResult.IsSystemFailure` seam
+
+**Classification:** implementation (additive contract field + adapter mapping)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T2, T7, T8.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs`,
+`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs`
+
+1. RED: extend `OtOpcUaLdapAuthServiceTests` — library `ServiceAccountBindFailed` adapts to
+   `IsSystemFailure == true`; `BadCredentials`/`UserNotFound`/success adapt to `false`.
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~OtOpcUaLdapAuthServiceTests"`
+   → **expected: FAIL**.
+2. GREEN: add `public bool IsSystemFailure { get; init; }` (XML-doc: "system-side directory failure —
+   unreachable/misconfigured — as opposed to a user-credential deny; feeds the OPC UA authenticator's
+   outage circuit"); set it in `Adapt`'s failure branch for `ServiceAccountBindFailed`. Re-run →
+   **PASS** (whole class — existing adapt tests must stay green).
+
+Commit: `feat(security): LdapAuthResult.IsSystemFailure distinguishes directory outage from credential deny (03/S2)`
+
+---
+
+### R2-08-T4 — Authenticator wall-clock bound + concurrency cap (turns T1 green)
+
+**Classification:** implementation (the core S2 fix)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7-T13 (S3 files). Blocked by T1, T2.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs`
+
+1. Add `IOptions` to the ctor; move the existing method body to
+   `private async Task AuthenticateCoreAsync(...)` unchanged; new outer body:
+   semaphore `WaitAsync(AuthTimeoutMs, ct)` → deny `"Authentication backend busy"` on false; start
+   `core = AuthenticateCoreAsync(...)` with a `core.ContinueWith(_ => _gate.Release(), TaskScheduler.Default)`
+   release-on-completion; `await core.WaitAsync(TimeSpan.FromMilliseconds(AuthTimeoutMs), ct)` in
+   try/catch — `TimeoutException` ⇒ Warning log + deny `"Authentication timed out"`. Update the class
+   XML-doc (why: SDK sync callback under `m_eventLock`).
+2. Update the six existing `LdapOpcUaUserAuthenticatorTests` constructions to pass
+   `Options.Create(new LdapOptions())`.
+3. Add `Authenticate_fast_success_unaffected` and
+   `Authenticate_saturated_concurrency_denies_busy` (`MaxConcurrentAuthentications = 1`, first call
+   parked in the fake, second denies "busy" within bound) to `LdapAuthResilienceTests`.
+4. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests|FullyQualifiedName~LdapOpcUaUserAuthenticatorTests"`
+   → **expected: PASS** (T1's repro now green; all six pre-existing tests green).
+
+Commit: `feat(host): hard wall-clock bound + bounded concurrency on OPC UA LDAP authentication (03/S2)`
+
+---
+
+### R2-08-T5 — Directory-outage circuit (fail-fast during sustained outage)
+
+**Classification:** implementation (resilience state machine)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7-T13. Blocked by T3, T4.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs`
+
+1. RED: add circuit tests — three consecutive timeouts (or `IsSystemFailure` denies) open the circuit:
+   the 4th call denies `"Authentication backend unavailable"` **without** invoking the fake (call
+   counter pinned at 3) and returns in ~0 ms; a user-side deny (`Invalid username or password`,
+   `IsSystemFailure=false`) between failures resets the streak; after `OutageCooldownSeconds` (use 1 s
+   in test) the next call probes the fake (half-open) and a success closes the circuit;
+   `OutageFailureThreshold = 0` never opens.
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests"`
+   → **expected: FAIL** (new tests only).
+2. GREEN: small lock-guarded state (`_consecutiveSystemFailures`, `_circuitOpenUntil` via
+   `Environment.TickCount64`); entry check denies while open (Debug log; single Warning when opening,
+   Information when closing); system failure = boundary timeout ∨ `result.IsSystemFailure` ∨ unexpected
+   throw from the core; success or user-side deny resets. Re-run → **PASS**.
+
+Commit: `feat(host): LDAP directory-outage circuit — fail-fast denial during sustained outage (03/S2)`
+
+---
+
+### R2-08-T6 — Outage-sim integration test + `HandleImpersonation` contract doc
+
+**Classification:** integration test (offline-safe) + doc
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7-T13. Blocked by T4.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs`,
+`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`, `docs/security.md`
+
+1. Add `Activation_with_unreachable_directory_fails_within_bound`: real `OtOpcUaLdapAuthService`
+   (real `ZB.MOM.WW.Auth` library, no fake) with `Server = "10.255.255.1"`, `Port = 3893`,
+   `Transport = None`, `AllowInsecure = true`, `ConnectionTimeoutMs = 500`, `AuthTimeoutMs = 1000`;
+   wrap in `LdapOpcUaUserAuthenticator`; drive the real `internal static`
+   `OpcUaApplicationHost.HandleImpersonation` with a `UserNameIdentityToken` (precedent:
+   `OpcUaApplicationHostImpersonationTests`); assert `args.IdentityValidationError` is
+   `BadIdentityTokenRejected` and total elapsed < ~3 s. (This is the automated stand-in for
+   "stop the GLAuth container" — a TCP blackhole is the same outage shape; the bitnami/openldap `:3894`
+   harness is not needed for the outage direction.)
+2. Rewrite `HandleImpersonation`'s doc comment: verified 1.5.378 contract (synchronous `void` handler
+   raised under `SessionManager.m_eventLock` — a stalled handler serializes *all* activations), why the
+   block-bridge is irreducible, and where the wall-clock bound + circuit live.
+3. Document the new `Security:Ldap` keys + semantics in `docs/security.md`.
+4. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests"`
+   → **expected: PASS**.
+
+Commit: `test(host): unreachable-directory activation fails bounded; document the SDK impersonation threading contract (03/S2)`
+
+---
+
+### R2-08-T7 — S3 sequential-serving repro test (RED — must fail on current code)
+
+**Classification:** test-first repro (deterministic — concurrency counter, not raw timing)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6, T8.
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs` (new)
+
+1. New class reusing the `NodeManagerHistoryReadTests` boot pattern (`BootAsync` + `EnsureVariable` +
+   public `HistoryRead` invoke). Fake `IHistorianDataSource` whose `ReadRawAsync` does
+   `Interlocked.Increment` a live-counter, records the max, `await Task.Delay(150ms, ct)`, decrements,
+   returns one sample.
+2. Add `Raw_batch_is_served_with_bounded_parallelism`: materialise 8 historized variables; issue one
+   Raw `HistoryRead` naming all 8; assert `fake.MaxObservedConcurrency >= 2` (primary — deterministic)
+   and elapsed < ~1.0 s (secondary, loose). Wrap the invoke in `Task.Run(...).WaitAsync(15 s)`.
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Raw_batch_is_served_with_bounded_parallelism"`
+   → **expected: FAIL** (`MaxObservedConcurrency == 1` on the sequential `foreach`).
+
+Commit: `test(opcuaserver): red repro — HistoryRead serves a batch strictly sequentially (03/S3)`
+
+---
+
+### R2-08-T8 — `ServerHistorian` keys + hosted-service wiring
+
+**Classification:** implementation (options + wiring, additive)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6, T7.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs`,
+`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (properties only),
+`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs` (or the options
+test file beside it)
+
+1. RED: options tests — defaults (`4` / `16` / `60 s`); `Validate()` warns on non-positive
+   parallelism/batches/deadline when `Enabled` (and stays silent when disabled).
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ServerHistorianOptions|FullyQualifiedName~AddServerHistorianTests"`
+   → **expected: FAIL**.
+2. GREEN: add `HistoryReadBatchParallelism = 4`, `MaxConcurrentHistoryReadBatches = 16`,
+   `HistoryReadDeadline = TimeSpan.FromSeconds(60)` (+ XML-doc each: what it bounds, the
+   ⌈N/P⌉ × CallTimeout worst case, 16 × 4 = 64 in-flight cap) + `Validate()` warnings; add the three
+   settable node-manager properties (defaults matching — `MaxTieClusterOverfetch` precedent, unused
+   until T9-T13); assign all three in `OtOpcUaServerHostedService.StartAsync` beside the existing
+   `MaxTieClusterOverfetch` line. Re-run → **PASS**.
+
+Commit: `feat(runtime): ServerHistorian HistoryRead parallelism/limiter/deadline knobs + node-manager wiring (03/S3)`
+
+---
+
+### R2-08-T9 — Async-ify `ServeNode` + Processed/AtTime arms (still sequential)
+
+**Classification:** implementation (mechanical async refactor — behavior-neutral)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6. Blocked by T7 (repro must be red first), T8 (ct/property names).
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
+
+1. `ServeNode` → `private async Task ServeNodeAsync(handle, results, errors, read, CancellationToken ct)`;
+   the `read` callback gains the token (`Func>`);
+   `await` instead of bridging; add `catch (OperationCanceledException)` → `errors/results = BadTimeout`
+   ahead of the generic catch. Processed/AtTime arms: pass `ct` into the source calls
+   (`:1841`/`:1865` today `CancellationToken.None`), collect the per-handle tasks but for now run them
+   sequentially with a single arm-boundary bridge (`foreach … ServeNodeAsync(...).GetAwaiter().GetResult()`
+   is acceptable interim — the fan-out lands in T11). Thread a `ct` parameter down from the arm (pass
+   `CancellationToken.None` at the arm until T12).
+2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadTests"`
+   → **expected: PASS** (behavior-neutral; T7's repro stays red).
+
+Commit: `refactor(opcuaserver): async-ify ServeNode + Processed/AtTime HistoryRead internals, thread cancellation (03/S3)`
+
+---
+
+### R2-08-T10 — Async-ify `ServeRawPaged` + Events body
+
+**Classification:** implementation (mechanical async refactor — behavior-neutral)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6. Blocked by T9 (shared `BadTimeout` pattern/signature conventions).
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
+
+1. `ServeRawPaged` → `ServeRawPagedAsync(..., CancellationToken ct)`: `await` the page read (`:2171`)
+   and the tie-cluster over-fetch (`:2203`) with `ct`; add the OCE → `BadTimeout` arm; continuation
+   store calls unchanged (thread-safe, audited).
+2. Extract the `HistoryReadEvents` per-handle body into `ServeEventsAsync(handle, sourceName, details,
+   selectClauses, results, errors, ct)` — `await` the `:1911` read with `ct`; OCE → `BadTimeout`;
+   existing catch preserved. Both arms keep the interim sequential arm-boundary bridge.
+3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadTests|FullyQualifiedName~NodeManagerHistoryReadPagingTests"`
+   → **expected: PASS** (paging + tie-cluster suites are the regression net).
+
+Commit: `refactor(opcuaserver): async-ify ServeRawPaged + HistoryReadEvents internals (03/S3)`
+
+---
+
+### R2-08-T11 — Bounded per-node fan-out in all four arms (turns T7 green)
+
+**Classification:** implementation (the core S3 parallelism fix)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6. Blocked by T9, T10.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs`
+
+1. Add `private void RunBounded(IReadOnlyList> work, int degree)`: `degree <= 1` or single
+   item ⇒ run sequentially; else `SemaphoreSlim(degree)`-gated `Task.WhenAll`, bridged once
+   (`GetAwaiter().GetResult()`; safe — every body is self-contained try/catch and never throws;
+   document that invariant on the helper).
+2. Rewrite the four arms: pre-filter the cheap synchronous rejections inline (IsReadModified,
+   non-notifier Events handles), collect one `Func` per remaining handle, call
+   `RunBounded(work, HistoryReadBatchParallelism)`.
+3. Extend the concurrency test class: `Parallelism_is_bounded_above` (12 nodes, property set to 3 →
+   `MaxObservedConcurrency <= 3`) and `One_node_failure_does_not_poison_a_parallel_batch` (one handle's
+   fake throws → that node `BadHistoryOperationUnsupported`, the other 7 Good).
+4. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryRead"`
+   → **expected: PASS** (T7 repro green; paging + base suites green).
+
+Commit: `feat(opcuaserver): bounded per-node parallel HistoryRead within a batch (03/S3)`
+
+---
+
+### R2-08-T12 — Per-request deadline
+
+**Classification:** implementation (deadline CTS + BadTimeout surfacing)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6. Blocked by T11.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs`
+
+1. RED: `Deadline_bounds_a_hung_backend` — fake whose reads complete only on token cancellation
+   (`await Task.Delay(Timeout.Infinite, ct)`); `HistoryReadDeadline = 500 ms`; 4 nodes → every node
+   `BadTimeout`, call returns within ~2 s (invoke wrapped in `.WaitAsync(10 s)`).
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Deadline_bounds_a_hung_backend"`
+   → **expected: FAIL** (hangs 30 s+ / `WaitAsync` trips — nothing cancels today).
+2. GREEN: each arm creates `using var cts = HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null`
+   and passes `cts?.Token ?? CancellationToken.None` down (defensive: non-positive = unbounded,
+   matching the options warning). Re-run → **PASS**.
+
+Commit: `feat(opcuaserver): per-request HistoryRead deadline — hung backends surface BadTimeout (03/S3)`
+
+---
+
+### R2-08-T13 — Process-wide concurrent-batch limiter
+
+**Classification:** implementation (server-side admission control)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1-T6. Blocked by T12.
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`,
+`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs`
+
+1. RED: `Saturated_limiter_rejects_with_BadTooManyOperations` — `MaxConcurrentHistoryReadBatches = 1`,
+   `HistoryReadDeadline = 200 ms`; first batch parked in a gated fake; a second concurrent
+   `HistoryRead` → all its handles `BadTooManyOperations`, returns within ~1 s; release the gate; a
+   third read succeeds (slot freed).
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Saturated_limiter_rejects_with_BadTooManyOperations"`
+   → **expected: FAIL** (no limiter exists).
+2. GREEN: lazily-created `SemaphoreSlim` (double-checked, sized from `MaxConcurrentHistoryReadBatches`
+   at first use — respects the post-boot property set, same benign race as `MaxTieClusterOverfetch`);
+   arm entry `Wait(min(HistoryReadDeadline, 5 s))` — on false, set `BadTooManyOperations` on every
+   handle and return; `try/finally` release. Re-run → **PASS**.
+
+Commit: `feat(opcuaserver): process-wide HistoryRead batch limiter — flood degrades to BadTooManyOperations, not pool exhaustion (03/S3)`
+
+---
+
+### R2-08-T14 — Docs + full regression sweep
+
+**Classification:** docs + verification
+**Estimated implement time:** 5 min
+**Parallelizable with:** —. Blocked by T5, T6, T13.
+**Files:** `docs/Historian.md`, `docs/security.md` (if residue from T6), `CLAUDE.md`
+(`ServerHistorian` key table), `archreview/plans/STATUS.md`
+
+1. Add the three `ServerHistorian` keys to `docs/Historian.md` + the CLAUDE.md table (defaults +
+   one-line semantics); confirm `docs/security.md` covers the five `Security:Ldap` keys.
+2. Full touched-suite sweep:
+   `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests`
+   (or per-project) → all green; then whole-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` (the CI
+   fail-on-skip gate applies).
+3. Record R2-08 outcome in `archreview/plans/STATUS.md` (branch, commits, test counts).
+
+Commit: `docs: ServerHistorian HistoryRead knobs + Security:Ldap resilience keys; R2-08 status (03/S2, 03/S3)`
+
+---
+
+## Post-merge (not gating, operator-run)
+
+- **S2 live check:** docker-dev rig — unreachable `Security:Ldap:Server` → Client.CLI UserName connect
+  denies fast; restore shared GLAuth `10.100.0.35:3893` → normal login. Optionally stop/start the
+  GLAuth container on the docker host for a true outage/recovery cycle (circuit opens, then closes).
+- **S3 live check:** env-gated `Category=LiveIntegration` gateway suite on the VPN; a 50-node Raw
+  HistoryRead soak against the real gateway while a second client stays interactive.
+
+## Overall effort
+
+**M (≈1.5-2 dev-days):** 6 tasks S2 + 7 tasks S3 + 1 docs/sweep, each ≤5 min of implementation but with
+the usual test-run + review cadence. Risk concentrated in T11 (parallel fan-out — mitigated by the
+thread-safety audit + the paging regression suite) and T4/T5 (auth-path semantics — mitigated by the
+fail-closed default posture and behavior-neutral defaults).
diff --git a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json
new file mode 100644
index 00000000..b48a529d
--- /dev/null
+++ b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json
@@ -0,0 +1,20 @@
+{
+  "planPath": "archreview/plans/R2-08-ldap-historyread-async-plan.md",
+  "tasks": [
+    { "id": "R2-08-T1", "subject": "S2 stall-repro test (RED): slow LDAP backend must deny within AuthTimeout — fails on current code", "status": "pending", "blockedBy": [] },
+    { "id": "R2-08-T2", "subject": "LdapOptions: ConnectionTimeoutMs/AuthTimeoutMs/MaxConcurrentAuthentications/OutageFailureThreshold/OutageCooldownSeconds + ToLibraryOptions projection + validator rules", "status": "pending", "blockedBy": [] },
+    { "id": "R2-08-T3", "subject": "LdapAuthResult.IsSystemFailure seam + OtOpcUaLdapAuthService.Adapt mapping (ServiceAccountBindFailed => system-side)", "status": "pending", "blockedBy": [] },
+    { "id": "R2-08-T4", "subject": "LdapOpcUaUserAuthenticator: WaitAsync wall-clock bound + SemaphoreSlim concurrency cap with release-on-core-completion (turns T1 green)", "status": "pending", "blockedBy": ["R2-08-T1", "R2-08-T2"] },
+    { "id": "R2-08-T5", "subject": "Directory-outage circuit: fail-fast deny after N consecutive system-side failures, half-open after cooldown", "status": "pending", "blockedBy": ["R2-08-T3", "R2-08-T4"] },
+    { "id": "R2-08-T6", "subject": "Outage-sim integration test (real library, unroutable host, via internal HandleImpersonation) + SDK m_eventLock contract doc + docs/security.md", "status": "pending", "blockedBy": ["R2-08-T4"] },
+    { "id": "R2-08-T7", "subject": "S3 sequential-serving repro test (RED): MaxObservedConcurrency >= 2 on an 8-node Raw batch — fails on current code", "status": "pending", "blockedBy": [] },
+    { "id": "R2-08-T8", "subject": "ServerHistorianOptions: HistoryReadBatchParallelism/MaxConcurrentHistoryReadBatches/HistoryReadDeadline + Validate warnings + node-manager properties + hosted-service wiring", "status": "pending", "blockedBy": [] },
+    { "id": "R2-08-T9", "subject": "Async-ify ServeNode + Processed/AtTime arms (ct threading, OCE=>BadTimeout, behavior-neutral)", "status": "pending", "blockedBy": ["R2-08-T7", "R2-08-T8"] },
+    { "id": "R2-08-T10", "subject": "Async-ify ServeRawPaged (page read + tie-cluster over-fetch) + extract ServeEventsAsync", "status": "pending", "blockedBy": ["R2-08-T9"] },
+    { "id": "R2-08-T11", "subject": "RunBounded fan-out in all four HistoryRead arms, single bridge per arm (turns T7 green) + upper-bound + isolation tests", "status": "pending", "blockedBy": ["R2-08-T9", "R2-08-T10"] },
+    { "id": "R2-08-T12", "subject": "Per-request HistoryRead deadline CTS — hung backend surfaces BadTimeout per node", "status": "pending", "blockedBy": ["R2-08-T11"] },
+    { "id": "R2-08-T13", "subject": "Process-wide concurrent-batch limiter — saturation degrades to BadTooManyOperations (lazy gate, bounded wait)", "status": "pending", "blockedBy": ["R2-08-T12"] },
+    { "id": "R2-08-T14", "subject": "Docs (Historian.md + CLAUDE.md ServerHistorian table + security.md) + full touched-suite and whole-solution regression sweep + STATUS.md entry", "status": "pending", "blockedBy": ["R2-08-T5", "R2-08-T6", "R2-08-T13"] }
+  ],
+  "lastUpdated": "2026-07-12"
+}
diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md b/archreview/plans/R2-09-driver-fleet-batch-plan.md
new file mode 100644
index 00000000..2bfe4d8c
--- /dev/null
+++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md
@@ -0,0 +1,571 @@
+# Design + Implementation Plan — R2-09 Driver Fleet Carried Batch
+
+- **Source report:** `archreview/05-protocol-drivers.md` (2026-07-12 re-review)
+- **Overall action item:** #9 in `archreview/00-OVERALL.md` — "Driver fleet carried batch: Modbus fatal-classification, AbCip/FOCAS locking, `ResolveHost`, `onError`, TwinCAT replay retry + Bad-quality"
+- **Plan verified against tree at:** `f6eaa267` (master, clean) — every anchor below re-opened and re-located in the working tree on 2026-07-12
+- **Round-1 plan built on:** `archreview/plans/05-protocol-drivers-plan.md` Parts A (A3/A4/A5) and B (B1 PollGroupEngine v2, B3 ResolveHost-via-resolver, B4 ConnectionBackoff) — designs verified still valid and extended below
+- **Scope:** `src/Drivers/` fleet findings — 05/STAB-3, 05/STAB-4, 05/STAB-5, 05/STAB-8 (fleet part), 05/STAB-9 + CONV-1 + PERF-3 + STAB-6-S7 (PollGroupEngine v2 batch), CONV-4 (`ResolveHost`), 05/STAB-16, 05/STAB-17, 05/STAB-12 (TwinCAT-compounded part only)
+- **Scope boundary (do NOT duplicate):**
+  - **Plan R2-01** owns the S7-specific OCE/fatal-classification fixes (STAB-14 ensure-wrapper OCE filter at `S7Driver.cs:488/:1000`, STAB-15 framing/cancel fatal-classification + probe mark-dead, and the S7 `EnsureConnectedAsync` attempt-throttle wiring). This plan hands R2-01 two seams: the `ConnectionBackoff` primitive (sub-batch B3/B4) and the engine-level OCE hardening that makes the S7 fork retirement safe (B3). Sequencing notes inline.
+  - **Plan R2-11** owns `ReadEnum`/TagConfig parse-strictness consolidation (CONV-2/UNDER-6). B5 adds exactly one key (`unitId`) to `ModbusEquipmentTagParser` because per-host breaker keying is impossible without it; all other parser-parity work stays with R2-11.
+
+---
+
+## Verification summary
+
+Every finding was re-opened at its cited file:line in the working tree at `f6eaa267`. **All 10 findings CONFIRMED; 2 anchors drifted (details below); 0 stale.**
+
+| Finding | Cited anchor | Verified at `f6eaa267` |
+|---|---|---|
+| STAB-3 | `ModbusTcpTransport.cs:155-165, 233-235, 257-261` | **EXACT** — retry catch `:155-165` (only `IsSocketLevelFailure`), per-op linked CTS `CancelAfter` `:226-227`, TxId-mismatch `InvalidDataException` `:233-235`, truncated-length `:236-237`, `IsSocketLevelFailure` `:257-261`, `TearDownAsync` `:263-270` |
+| STAB-4 | `AbCipDriver.cs:544-575, 614-637, 708-726` (prompt's "~548-579 +4" was conservative) | **EXACT to the report** — `ReadSingleAsync` critical section `:544-575`, `ReadGroupAsync` `:614-637`, `WriteAsync` Encode→Write→GetStatus `:708-726`; zero runtime locking (only `GetRmwLock` at `DeviceState:1243`, bit-RMW only); alarm-projection read loop `AbCipAlarmProjection.cs:230-232` hits the same handles; AbLegacy's guard confirmed at `AbLegacyDriver.cs:227-244` (read) / `:331-342` (write) / `_runtimeLocks` + `GetRuntimeLock` `:773-787` / dispose `:841-842` |
+| STAB-5 | `FocasDriver.cs:1184-1194, 752-839, 912-945` | **EXACT** — plain `Dictionary` fields `LastFixedSnapshots :1184`, `LastTimers :1190`, `LastServoLoads :1192`, `LastSpindleLoads :1194`; loop writes `:700, :752, :793, :836-839, :862-863, :874`; reader-thread reads `:878, :912, :920, :945`; contrast `_parsedAddressesByTagName` already `ConcurrentDictionary` `:40` |
+| STAB-9 (dead `onError`) | 5 ctor sites | **EXACT** — `ModbusDriver.cs:115`, `AbCipDriver.cs:133`, `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72` — all five pass `reader:`/`onChange:` only; `PollGroupEngine.cs` `onError` param `:59`, `ReportError` `:164-169` dead fleet-wide |
+| CONV-1/PERF-3/STAB-6-S7 (S7 fork) | `S7Driver.cs:1373-1412, 1447, 1469, 1350-1354` | **EXACT** — fork `PollLoopAsync :1373-1412` (bare `catch (OCE) { return; }` `:1383, :1397, :1404`), `HandlePollFailure :1421-1437`, `ComputeBackoffDelay :1447-1456`, reference-equality diff `:1469`, `UnsubscribeAsync` cancel+dispose-without-drain `:1350-1354`. Engine already has everything the fork lacks: `StopState` drain `PollGroupEngine.cs:99-112`, structural array compare `:203-209`, `ObjectDisposedException` guard `:136` — and lacks what the fork has: backoff + failure→health |
+| STAB-8 (fleet) | AbCip `:891-897`, FOCAS `:1089-1120`, TwinCAT `:706-721` | **CONFIRMED** — `EvictRuntime` `AbCipDriver.cs:891-897` + `EnsureTagRuntimeAsync :852-882` recreate at full Forward-Open cost per call; FOCAS `EnsureConnectedAsync :1089-1120` full two-socket reconnect per call, no throttle; TwinCAT `EnsureConnectedAsync :706-721` / `EnsureConnectedUnderGateAsync :731-775` connect per call, no throttle. Only Modbus transport (`ConnectWithBackoffAsync :179-208`) and the S7 poll loop have any backoff |
+| CONV-4 (`ResolveHost`) | 5 sites | **EXACT** — `AbCipDriver.cs:483-488`, `AbLegacyDriver.cs:496-501`, `TwinCATDriver.cs:689-696` (falls back to `UnresolvedHostSentinel` `""`, `:686`), `FocasDriver.cs:1082-1087`, `ModbusDriver.cs:125-131` (`BuildSlaveHostName` `:134`, `ResolveUnitId` `:474`). All consult `_tagsByName` only. All four multi-device `TDef`s carry `DeviceHostAddress` and all four equipment parsers parse `deviceHostAddress` (AbCip parser `:39`, AbLegacy `:42`, TwinCAT `:30`, FOCAS `:32` — FOCAS defaults to `""` when absent). `ModbusEquipmentTagParser` has **no** `unitId` key (grep-confirmed) |
+| STAB-16 | `TwinCATDriver.cs:801-816` | **CONFIRMED, minor drift** — the replay-failure catch is `:801-807` (log-only; `SwapHandle` runs only in the success path `:797`), summary log `:810-813`. Failed intent keeps its dead handle, `OnChange` is `Action` (no quality channel, `:575`), replay runs only from `EnsureConnectedUnderGateAsync :773` (i.e. only when a NEW client is installed — a healthy client never retries) |
+| STAB-17 | `TwinCATDriver.cs:608-613, 786-803` | **EXACT** — `NativeRegistration.Dispose :607-613` is gate-free (registry `TryRemove :610`, handle exchange+dispose `:611-612`); replay snapshots intents `:786` and `SwapHandle`s `:797` with no ownership re-check, so a dispose between snapshot and swap leaves the fresh handle ownerless and the ADS notification live |
+| STAB-12 (compounded part) | `TwinCATDriver.cs:835` | **DRIFTED −9** — the `ConnectGate.WaitAsync(CancellationToken.None)` is at `TwinCATDriver.cs:826` (method `RecycleClientAsync :824-840`); callers are the probe loop `:640` and `:648`, both holding a live `ct` |
+
+**Drifted anchors (2):** STAB-12's `RecycleClientAsync` gate-wait `:835` → **`:826`**; STAB-16's cited span `:801-816` is really catch `:801-807` + summary log `:810-813` (same method, cosmetic). Everything else verified exact.
+
+**Test infrastructure verified:** `PollGroupEngineTests.cs` (Core.Abstractions.Tests), `ModbusTcpReconnectTests.cs` with in-proc `FlakeyModbusServer` (TcpListener-based — extendable for stall/wrong-TxId/truncation), `AbLegacyRuntimeConcurrencyTests.cs` (the template for B2), `FakeAbCipTag.cs`, `FakeFocasClient.cs`, `TwinCATReconnectReplayTests.cs` (fake `ITwinCATClient`), `S7DriverReconnectTests.cs` + `S7DiscoveryAndSubscribeTests.cs` (fake `IS7PlcFactory`), env-gated `S7_1500ReconnectTests.cs`.
+
+**Live gates:** docker fixtures on `10.100.0.35` via `lmxopcua-fix`: **modbus** (`:5020`, `lmxopcua-fix up modbus standard`; the `exception_injector` container can force write/FC failures — see the write-outcome memory recipe), **abcip** (`:44818`, `up abcip controllogix`), **s7** (`:1102`, `up s7 s7_1500`; bounce = `down`/`up` for reconnect recipes), **opcuaclient** (`:50000`). **TwinCAT, FOCAS, and AbLegacy have NO fixture** — the fake-client unit suites are authoritative there (accepted in the STAB-2 remediation and re-affirmed by UNDER-7).
+
+---
+
+# Sub-batch B1 — Modbus transport fatal-classification (STAB-3, High)
+
+*Independently shippable. No dependencies.*
+
+## STAB-3 — Modbus transport desynchronizes permanently after a response timeout or TxId mismatch
+
+**Restatement:** a per-op timeout (`OperationCanceledException` from the linked `CancelAfter` CTS) or a framing violation (TxId mismatch / truncated header → `InvalidDataException`) is not classified socket-fatal, so the single-flight socket is kept with a half-read or still-in-flight response. The next transaction reads the stale bytes, throws on the TxId guard — also not socket-fatal — and the stream stays desynchronized forever (until a real socket error or the default-off idle-disconnect).
+
+**Verification:** CONFIRMED at `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`. `SendAsync`'s reconnect-retry catch (`:155`) filters on `IsSocketLevelFailure` (`:257-261` — only `EndOfStreamException`/`IOException`/`SocketException`/`ObjectDisposedException`). The per-op deadline is a linked CTS `CancelAfter(_timeout)` (`:226-227`) whose cancellation surfaces as OCE; the TxId guard throws `InvalidDataException` (`:234-235`); the length guard throws `InvalidDataException` (`:237`). None reach `TearDownAsync` (`:263-270`). Note the 05 report explicitly pairs this with S7's STAB-15 ("same defect class in two drivers") — the S7 side is **R2-01's**; only the Modbus transport is fixed here.
+
+**Root cause:** the transport conflates "protocol-layer error, socket still coherent" (Modbus exception PDU — correctly kept) with "framing/timeout error, unknown bytes may be in flight — the socket is unusable". Timeout and framing violations require teardown regardless of exception type.
+
+**Proposed design** (unchanged from round-1 plan A3, verified still correct): treat per-op timeout and any framing violation as **connection-fatal — tear down before propagating**, normalized as an `IOException` subclass so the existing retry classifier covers it with zero changes:
+
+- New `ModbusTransportDesyncException : IOException` (carries the desync reason: `Timeout`, `TxIdMismatch`, `TruncatedFrame`).
+- In `SendOnceAsync` (`:210-250`), wrap the write/read/parse body:
+  - `catch (OperationCanceledException) when (!ct.IsCancellationRequested)` — the linked timeout fired, not the caller ⇒ `await TearDownAsync()` ⇒ `throw new ModbusTransportDesyncException(...)`. **Caller cancellation (`ct.IsCancellationRequested`) propagates as OCE with NO teardown** — a legitimate shutdown must not look like a desync.
+  - `catch (InvalidDataException ide)` ⇒ `await TearDownAsync()` ⇒ `throw new ModbusTransportDesyncException(..., ide)`.
+- Because `ModbusTransportDesyncException` **is** an `IOException`, `SendAsync`'s existing single-retry (`:155-165`) now reconnects and resends once when `_autoReconnect` is on; when `_autoReconnect` is off the socket is still torn down (mandatory — a desynced socket must never be reused) and the exception propagates to the driver's status mapping (`BadCommunicationError`).
+
+*Alternative considered:* add `OperationCanceledException`/`InvalidDataException` to `IsSocketLevelFailure` — rejected: the retry catch runs teardown only when auto-reconnect is on, and OCE must be split caller-vs-timeout at the throw site anyway. The teardown-at-throw-site design covers auto-reconnect-off and keeps `IsSocketLevelFailure` a pure type test.
+
+**Implementation steps:**
+1. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs` — new file, `sealed class ModbusTransportDesyncException : IOException`.
+2. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs` — wrap `SendOnceAsync` body per design (two catch clauses + teardown). No change to `SendAsync`, `IsSocketLevelFailure`, or `ConnectWithBackoffAsync`.
+3. XML-doc the timeout-vs-caller-cancel distinction on `SendOnceAsync`.
+
+**Tests** (extend `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTcpReconnectTests.cs` — its `FlakeyModbusServer` (TcpListener, `:22-35`) gains scriptable behaviors):
+- `SendAsync_AfterResponseTimeout_TearsDownAndReconnects` — server stalls past the transport timeout on request N, answers correctly on N+1; assert request N throws (Bad path), the socket was replaced (server sees a new connection), and N+1 succeeds with the CORRECT response (no stale-read).
+- `SendAsync_AfterTxIdMismatch_TearsDownSocket` — server answers with a wrong TxId; assert `ModbusTransportDesyncException` (or successful single-retry when auto-reconnect on) and a fresh connection for the next call.
+- `SendAsync_AfterTruncatedHeader_TearsDownSocket` — server sends a short/garbage header then closes write side.
+- `SendAsync_CallerCancellation_DoesNotTearDown` — cancel the caller token mid-request; assert OCE propagates and the server does NOT see a reconnect on the next call.
+- Live gate (optional, modbus fixture): the `exception_injector` rig scenario from the round-1 plan — force a non-responding unit, assert recovery on the next poll.
+
+**Effort:** S. **Risk:** Low-Medium — the timeout-vs-caller-cancel filter is the one subtlety; an inverted filter turns every shutdown into a spurious reconnect (harmless but noisy) or every timeout into a permanent desync (the bug). The four unit tests pin both directions.
+
+---
+
+# Sub-batch B2 — Silent-corruption locking: AbCip runtime lock + FOCAS concurrent caches (STAB-4 + STAB-5, High)
+
+*Independently shippable. No dependencies. Concurrency fixes — high-risk classification.*
+
+## STAB-4 — AbCip runs concurrent operations on shared libplctag handles with no serialization
+
+**Restatement:** `ReadSingleAsync`/`ReadGroupAsync`/`WriteAsync` execute Read→GetStatus→Decode / Encode→Write→GetStatus on cached `IAbCipTagRuntime` handles with zero locking, while the server read path, every poll-group loop, and the alarm-projection loop can hit the same handle concurrently. libplctag `GetStatus` returns the *last* operation's status → torn/cross-attributed values decoded with Good status. AbLegacy documents and guards this exact hazard; the fix never propagated.
+
+**Verification:** CONFIRMED. Unlocked critical sections at `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:544-575` (`ReadSingleAsync`: `EnsureTagRuntimeAsync`→`ReadAsync`→`GetStatus`→`DecodeValue/DecodeArray`), `:614-637` (`ReadGroupAsync`: same on the parent + `DecodeValueAt` per member), `:708-726` (`WriteAsync`: `EncodeValue`→`WriteAsync`→`GetStatus`). Handles are cached in `DeviceState.Runtimes` (`ConcurrentDictionary`, `:1226` — the *map* is safe, the *handles* are not). Concurrent callers confirmed: `AbCipAlarmProjection.RunPollLoopAsync` reads through `_driver.ReadAsync` (`AbCipAlarmProjection.cs:230-232`), plus `PollGroupEngine` loops (one per subscription) and server-path reads/writes. The AbLegacy template: `_runtimeLocks` `ConcurrentDictionary` + `GetRuntimeLock` (`AbLegacyDriver.cs:773-787`), applied around the read sequence (`:227-244`) and the write sequence (`:331-342`), disposed at teardown (`:841-842`). The RMW path (`WriteBitInDIntAsync :787-818`) already serializes via `GetRmwLock` (`DeviceState:1238-1244`) but only RMW-vs-RMW, and it operates on a **separate** handle cache (`ParentRuntimes :1235`) — so the runtime lock and the RMW lock govern disjoint handle sets and do not need merging (the STAB-11 direct-parent-write-vs-RMW lost-update is a *device-data* race across two handles, explicitly out of this batch — carried under STAB-11).
+
+**Root cause:** the AbLegacy fix never propagated to AbCip (report theme: fixes don't flow back to siblings). AbCip caches per-tag runtimes but never serializes operations on one.
+
+**Proposed design** (round-1 plan A4, port AbLegacy's per-runtime operation lock — verbatim pattern):
+- `DeviceState` gains `private readonly ConcurrentDictionary _runtimeLocks` + `public SemaphoreSlim GetRuntimeLock(string tagName)` (mirror `AbLegacyDriver.cs:786-787`), disposed in `DisposeHandles()` (mirror `:841-842`).
+- `ReadSingleAsync`: acquire `device.GetRuntimeLock(def.Name)` around the `ReadAsync`→`GetStatus`→`Decode*` sequence (`:545-573`); status-mapping/health/result-stamping can stay outside the lock (they touch no handle) but keeping the AbLegacy shape (compute `status`/`value` inside, act outside) is the cleanest port.
+- `ReadGroupAsync`: acquire `device.GetRuntimeLock(parent.Name)` around `ReadAsync`→`GetStatus`→per-member `DecodeValueAt` (`:615-636`) — the members decode from the parent handle's buffer, so they belong inside the same critical section.
+- `WriteAsync`: acquire `device.GetRuntimeLock(def.Name)` around `EncodeValue`→`WriteAsync`→`GetStatus` (`:709-712`).
+- Eviction interplay: `EvictRuntime` (`:891-897`) removes+disposes the handle while the evicting caller still holds the lock; the lock entry is deliberately NOT removed — a fresh runtime under the same name reuses it (AbLegacy precedent, avoids a remove/recreate race on the lock map).
+- Locks keyed per tag name ⇒ cross-tag parallelism preserved. *Alternative (per-device lock):* rejected — serializes independent tags, and AbLegacy's field-proven granularity is per-runtime.
+
+**Implementation steps:**
+1. `AbCipDriver.cs` `DeviceState` (`:1187-1281`): add `_runtimeLocks`, `GetRuntimeLock`, dispose in `DisposeHandles`.
+2. Wrap the three op bodies (`ReadSingleAsync`, `ReadGroupAsync`, `WriteAsync`) in `WaitAsync(ct)`/`finally Release`.
+3. XML-doc on `GetRuntimeLock` quoting the AbLegacy rationale ("a libplctag Tag handle is not safe for concurrent Read/GetStatus/DecodeValue").
+
+**Tests:**
+- New `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs` mirroring `AbLegacyRuntimeConcurrencyTests`: extend/derive from `FakeAbCipTag` with a reentrancy guard (an `Interlocked` in-op counter that records a violation when >1 thread is inside Read/GetStatus/Decode/Encode/Write on the same instance); hammer concurrent `ReadAsync` + `WriteAsync` + a group read against one tag; assert zero overlap violations and coherent statuses.
+- Regression: full existing AbCip suite (subscription/read/write/RMW tests) must stay green — the lock must not deadlock the RMW path (RMW uses `ParentRuntimes` handles, never `Runtimes`, so no nesting occurs; assert with the existing `AbCipBoolInDIntRmwTests`).
+- Live gate (optional): abcip fixture — concurrent subscribe + write on overlapping tags, values stay coherent.
+
+**Effort:** S. **Risk:** Medium (concurrency change on the hot data path, but strictly additive locking with a field-proven sibling template; deadlock risk nil — single lock level, no nesting).
+
+## STAB-5 — FOCAS fixed-tree caches are plain `Dictionary`s mutated across threads
+
+**Restatement:** four per-device caches are written by the background fixed-tree loop while `ReadAsync` callers read them concurrently — undefined behaviour on `Dictionary` (corruption or throw on resize).
+
+**Verification:** CONFIRMED at `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs`: `LastFixedSnapshots` (`Dictionary`, `:1184`), `LastTimers` (`Dictionary = []`, `:1190`), `LastServoLoads` (`Dictionary`, `:1192`), `LastSpindleLoads` (`Dictionary = []`, `:1194`). Writes from `FixedTreeLoopAsync` and its helpers (`:700, :752, :793, :836-839, :862-863, :874`); reads from `TryReadFixedTree` on `ReadAsync` callers' threads (`:878, :912, :920, :945`). The neighbouring `_parsedAddressesByTagName` already got the `ConcurrentDictionary` treatment (`:40`). All call sites use indexer-set and `TryGetValue` only — both supported by `ConcurrentDictionary` unchanged. (`LastProgramInfo`/`LastProgramAxisRef` are single reference-typed properties — atomic writes, not part of this finding.)
+
+**Root cause:** partial application of the concurrent-collection fix.
+
+**Proposed design** (round-1 plan A5): convert the four fields to `ConcurrentDictionary<,>`; `[]`/`new(...)` initializers become `new()`/`new(StringComparer.OrdinalIgnoreCase)`. *Alternative (immutable snapshot swap per loop pass):* cleaner isolation, more churn — rejected for the minimal type-only change matching the in-tree precedent at `:40`.
+
+**Implementation steps:** change the four field types on `FocasDriver.DeviceState` (`:1184-1194`) + initializers. Zero call-site changes.
+
+**Tests:** new `FocasFixedTreeConcurrencyTests.cs` in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/`: a stress test spinning writer iterations (mimic `PublishAxisSnapshot`-style keyed writes forcing dictionary growth/resize) against concurrent `TryGetValue` readers on the same `DeviceState`; N-thousand iterations, assert no exception. (On the plain `Dictionary` this reliably throws "operations that change non-concurrent collections must have exclusive access" / corrupts under resize.) No FOCAS fixture exists — this unit test is the authoritative gate.
+
+**Effort:** S. **Risk:** Very low — type-only change, FOCAS-local.
+
+---
+
+# Sub-batch B3 — PollGroupEngine v2: backoff + OCE hardening + `onError` fleet wiring + S7 fork retirement (STAB-9, STAB-8-poll-part, CONV-1, PERF-3, STAB-6-S7 — High/Medium)
+
+*The highest-leverage structural item. Contains concurrency-adjacent loop changes — high-risk classification for the S7 migration. Introduces the shared `ConnectionBackoff` primitive that B4 and plan R2-01 consume.*
+
+## Verification of the pieces
+
+- **Engine (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`)** already has: the `onError` sink (`:59, :164-169`), `StopState` drain-before-CTS-dispose (`:99-112`), `ObjectDisposedException`-on-Delay guard (`:136`), structural array compare (`:203-209`), reader-contract validation (`:178-181`). It lacks: failure backoff (fixed `Task.Delay(state.Interval)` `:131`) and — newly relevant — its `PollLoopAsync` treats **any** OCE as teardown (`catch (OperationCanceledException) { return; }` at `:122, :132, :139`), the exact shape that turned S7's connect-timeout OCE into permanent subscription death (STAB-14). If S7 migrates onto the engine without this filter, STAB-14 reproduces inside the shared engine.
+- **STAB-9** CONFIRMED: all five consumers construct without `onError` (`ModbusDriver.cs:115`, `AbCipDriver.cs:133`, `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72`) — a reader-contract violation or reader exception is silently swallowed fleet-wide.
+- **CONV-1/PERF-3/STAB-6-S7** CONFIRMED: S7's fork (`S7Driver.cs:1373-1412`) has capped exponential backoff (`ComputeBackoffDelay :1447-1456`, cap `PollBackoffCap :1363` = 30 s) and failure→health (`HandlePollFailure :1421-1437`, threshold `:1371`, never-downgrade-Faulted nuance `:1432-1435`) that the engine lacks; and lacks the structural array diff (`Equals(lastSeen?.Value, current.Value)` `:1469` — every subscribed array tag fires every tick) and the drain-before-dispose (`UnsubscribeAsync :1350-1354` cancels + disposes the CTS without awaiting `PollTask`; the loop's `Task.Delay` has no ODE guard) that the engine has. `SubscribeAsync :1328-1345`, `SubscriptionState :1477-1492`, teardown drains at `:269-270`/`:1605-1606`.
+- **STAB-8 poll-part** CONFIRMED: the engine polls at fixed cadence regardless of failures — a dead device pays full-cost reads every tick on Modbus/AbCip/AbLegacy/TwinCAT-fallback/FOCAS.
+
+**Root cause (CONV-1):** template divergence — each side owns fixes the other needs. Single home = extend the engine with the fork's two genuine improvements, then delete the fork.
+
+## Proposed design
+
+**1. `ConnectionBackoff` primitive** — new `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs`:
+- `public static TimeSpan ComputeDelay(TimeSpan baseInterval, int consecutiveFailures, TimeSpan cap)` — the S7 math ported verbatim (`S7Driver.cs:1447-1456`: shift-capped-at-30, tick overflow guard, saturate at cap; `failures <= 0 ⇒ baseInterval`).
+- `public sealed class ConnectionBackoff` — the per-device attempt-throttle B4/R2-01 consume: ctor `(TimeSpan baseDelay, TimeSpan maxDelay)`; `bool ShouldAttempt(DateTime nowUtc)` (true when outside the current backoff window), `void RecordFailure(DateTime nowUtc)`, `void RecordSuccess()` (reset to zero — recovery must never be delayed). **Not internally synchronized** — documented contract: callers invoke it under their existing per-device gate (`ConnectGate`/`_gate`/probe lock), which every intended call site already holds. *Alternative (lock-free Interlocked state):* rejected as needless complexity while every consumer already serializes.
+- This is the **seam handoff to R2-01**: S7's `EnsureConnectedAsync` attempt-throttle (STAB-8's "slightly worse on S7" leg) is wired by R2-01 using this type; B3 lands the primitive, B4 wires TwinCAT/FOCAS/AbCip.
+
+**2. Engine v2 (`PollGroupEngine`)** — three additive changes, all preserving current consumer behavior by default:
+- Ctor gains `TimeSpan? backoffCap = null`. `PollLoopAsync` tracks `consecutiveFailures` (reset on a successful `PollOnceAsync`, incremented in the catch), and the delay becomes `backoffCap is null ? state.Interval : ConnectionBackoff.ComputeDelay(state.Interval, consecutiveFailures, backoffCap.Value)`. Null cap ⇒ byte-identical current cadence.
+- **OCE hardening:** the three `catch (OperationCanceledException) { return; }` sites (`:122, :132, :139`) gain `when (ct.IsCancellationRequested)`; a *non-caller* OCE thrown by the reader (a driver-internal timeout CTS — the STAB-14/STAB-15 class) falls through to the generic `when (!IsFatal(ex))` handler ⇒ `ReportError` + backoff + continue. The `Task.Delay` catch at `:132` keeps its bare form (Delay's OCE can only be the loop token) — only the two `PollOnceAsync` catches change. This is the guard that makes the S7 migration safe regardless of R2-01's landing order.
+- Keep `onError` optional in the signature (fleet consumers all pass it after this batch; making it required is a breaking nicety deferred — noted, not done, to keep the change additive).
+- 
+**3. `onError` wiring — all five consumers.** Each construction gains `onError: ex => { /* degrade health, log */ }` implementing the S7 nuance: log warning; set `Degraded` preserving `LastSuccessfulRead`; **never downgrade `Faulted`** (`_health.State != DriverState.Faulted` guard — S7Driver.cs:1432-1435). Per-driver health idiom follows each driver's existing convention (Modbus `WriteHealth` Volatile helper; AbCip/TwinCAT plain `_health` field; AbLegacy `volatile`; FOCAS Volatile) — CONV-5 idiom unification is explicitly NOT this batch.
+- Also pass `backoffCap: TimeSpan.FromSeconds(30)` in all five (adopting S7's proven cap fleet-wide) — this closes the STAB-8 subscriber-side leg for the whole fleet.
+
+**4. S7 fork retirement.** Replace `_subscriptions`/`SubscribeAsync`/`UnsubscribeAsync`/`PollLoopAsync`/`PollOnceAsync`/`HandlePollFailure`/`SubscriptionState`/`S7SubscriptionHandle`/`PollBackoffCap`/`PollFailureHealthThreshold` (`S7Driver.cs:1328-1499` + field `:59` + teardown drains `:269-270`, `:1605-1606`) with a `PollGroupEngine` instance constructed like the other five (reader `ReadAsync` — method-group reference, same OTOPCUA0001-clean shape the other five use; onChange forwards `OnDataChange`; onError = the extracted failure handler; `minInterval` default 100 ms — matches the fork's floor `:1335-1337`; `backoffCap` 30 s). `ComputeBackoffDelay` moves to `ConnectionBackoff.ComputeDelay` (S7's copy deleted; its unit asserts move to `ConnectionBackoffTests`). Teardown paths swap the manual drain for `_poll.DisposeAsync()`. This deletes PERF-3 (structural compare inherited), STAB-6-S7 (StopState drain inherited), and the fork's bare-OCE poll-death (superseded by the hardened engine catches).
+
+**Sequencing vs R2-01:** independent in either order. If R2-01 lands first, its poll-loop OCE-filter edits (`:1383/:1397/:1404`) are simply deleted by this migration (its ensure-wrapper fixes at `:488/:1000` are on the read/write path and survive untouched). If B3 lands first, the engine's hardened OCE catch already prevents timeout-OCE from killing the loop, and R2-01's remaining scope is the wrapper rethrow filter + fatal classification + probe mark-dead. Cross-reference this in both PRs.
+
+## Implementation steps
+
+1. New `Core.Abstractions/ConnectionBackoff.cs` (static `ComputeDelay` + instance throttle) + `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs` (schedule parity with the S7 math: 0 failures ⇒ base, doubling, cap saturation, overflow guard; `ShouldAttempt` window semantics; `RecordSuccess` resets immediately).
+2. `PollGroupEngine.cs`: `backoffCap` param + `consecutiveFailures` + `ConnectionBackoff.ComputeDelay`; OCE `when (ct.IsCancellationRequested)` filters on the two `PollOnceAsync` catches. Extend `tests/Core/.../PollGroupEngineTests.cs`.
+3. Wire `onError` (+ 30 s `backoffCap`) into `ModbusDriver.cs:115`, `AbCipDriver.cs:133`, `AbLegacyDriver.cs:65`, `TwinCATDriver.cs:69`, `FocasDriver.cs:72` — each with a small private `HandlePollError(Exception)` following the driver's health idiom.
+4. S7: construct the engine in the ctor; delete the fork block (`:1328-1499` minus what moves); route `SubscribeAsync`/`UnsubscribeAsync` to `_poll`; swap teardown drains for `_poll.DisposeAsync()`; delete `ComputeBackoffDelay` (now in `ConnectionBackoff`).
+
+## Tests
+
+- `ConnectionBackoffTests`: as step 1 (port the existing S7 backoff asserts — grep `ComputeBackoffDelay` under `tests/Drivers/.../S7.Tests`).
+- `PollGroupEngineTests` additions: `Backoff_SustainedReaderFailures_StretchesCadenceToCap` + `Backoff_SuccessResetsToInterval` (virtual-time via short intervals + tick counting); `ReaderOperationCanceled_WithoutCallerCancellation_TreatedAsFailureNotTeardown` (reader throws `new OperationCanceledException()` with the caller token un-cancelled; assert the loop survives, `onError` fired, next tick polls again) — **this is the STAB-14-class regression guard at the engine level**; `CallerCancellation_StillExitsPromptly`.
+- Per-driver `onError` tests (one per driver, in each driver's `.Tests`): reader forced to throw (e.g. contract-violation via a fake returning a short snapshot list, or a throwing fake client) ⇒ assert health transitions to `Degraded` with `LastSuccessfulRead` preserved, and a pre-existing `Faulted` is not downgraded.
+- S7 migration guards (in `S7.Tests`): `ArrayTag_UnchangedBetweenPolls_DoesNotRefire` (PERF-3 regression — fresh array instances, identical contents, assert one initial push only); `RapidSubscribeUnsubscribe_NoObjectDisposedException` (STAB-6 regression); `SustainedPollFailure_DegradesHealth_WithBackoff` (parity with the deleted `HandlePollFailure`); full existing `S7DiscoveryAndSubscribeTests` + `S7DriverReconnectTests` stay green.
+- Live gate: s7 fixture (`lmxopcua-fix up s7 s7_1500`, `:1102`) — subscribe, bounce the container, assert values resume (the existing env-gated `S7_1500ReconnectTests` recipe now also exercises the engine-based loop).
+
+**Effort:** M (engine S; five wirings S; S7 migration M). **Risk:** Medium-High — the S7 migration touches its subscription surface and alters array publish cadence (intended, PERF-3); the engine OCE filter changes loop-exit semantics (guarded by the new engine tests). Blast radius otherwise bounded: null `backoffCap` consumers (none remain in-tree after step 3) and existing engine consumers keep byte-identical behavior until they opt in.
+
+---
+
+# Sub-batch B4 — Connect-attempt throttles for the lazy-reconnect drivers (STAB-8 fleet part, Medium)
+
+*Depends on B3 step 1 (`ConnectionBackoff`). Independently shippable after that. S7 wiring explicitly excluded — R2-01 seam.*
+
+## STAB-8 — unthrottled reconnect attempts against a dead device
+
+**Restatement:** a dead device pays full reconnect cost on every data call / poll tick: TwinCAT a connect attempt per call, FOCAS a full two-socket reconnect per tick of each of three loops, AbCip a full `Tag` create + Forward Open per tag per tick via evict-recreate. (S7's new per-call reconnect is the same defect — **R2-01's wiring**, same primitive.)
+
+**Verification:** CONFIRMED — TwinCAT `EnsureConnectedAsync`/`EnsureConnectedUnderGateAsync` (`TwinCATDriver.cs:706-775`) attempts a connect on every call while down; FOCAS `EnsureConnectedAsync` (`FocasDriver.cs:1089-1120`) unconditionally builds + connects a fresh `SynchronizedFocasClient`; AbCip `EnsureTagRuntimeAsync` (`AbCipDriver.cs:852-882`) re-creates + re-initializes (Forward Open) a runtime per call after eviction (`EvictRuntime :891-897`). B3 closes the *subscriber-side* cost (poll backoff stretches tick cadence); this closes the *connect-side* cost for server-initiated reads/writes and multi-loop drivers.
+
+**Proposed design:** per-device `ConnectionBackoff` gate on the connect/create path, with the **probe loop as the recovery agent**:
+- Data-path callers consult `backoff.ShouldAttempt(now)` before an actual connect/create attempt; inside the window they fail fast with the same exception type the failed connect would have thrown (drivers already map it to `BadCommunicationError` snapshots / write results) — no wire traffic.
+- The **probe loop bypasses the throttle** (its own interval is already a bounded cadence) and drives `RecordFailure`/`RecordSuccess`. Success on any path (probe or a data call that squeaked through at a window boundary) resets the backoff instantly — recovery is never delayed beyond one probe interval, and typically not at all (first post-recovery data call inside an expired window succeeds and resets).
+- Defaults: base 1 s, max 30 s (matches Modbus transport + S7 poll conventions). Not config-exposed in this pass (no options-surface churn; note for CONV-8's future options `Validate()` work).
+
+Per driver:
+- **TwinCAT:** `DeviceState` gains `ConnectionBackoff Backoff`; `EnsureConnectedUnderGateAsync` (under `ConnectGate` — the documented synchronization home) checks `ShouldAttempt` before building a client when a bypass flag is not set, records failure in its existing connect catch (`:759-767`) and success after `device.Client = client` (`:768`); `EnsureConnectedAsync` gains a `bypassBackoff` parameter the probe loop (`:631`) sets true.
+- **FOCAS:** same shape on `FocasDriver.DeviceState` + `EnsureConnectedAsync` (`:1089`); the probe loop's `EnsureConnectedAsync` call passes bypass; the fixed-tree and recycle loops go through the throttled path (they're the per-tick hammer the finding cites).
+- **AbCip:** `DeviceState` gains `ConnectionBackoff Backoff`; `EnsureTagRuntimeAsync` checks `ShouldAttempt` **only on the create path** (cache hits unaffected, `:855`); `RecordFailure` in the init catch (`:867-875` — only for transport-shaped failures: keep it simple and record on any `InitializeAsync` throw), `RecordSuccess` after a successful `TryAdd`; the probe loop bypasses (its own runtime create) and records success/failure. Eviction (`EvictRuntime`) does not touch the backoff — the next create attempt decides.
+
+*Alternative — throttle inside `PollGroupEngine`:* rejected; B3's poll backoff already stretches subscription cadence, and the connect cost also burns on server-initiated reads which never pass through the engine.
+
+**Implementation steps:**
+1. (`ConnectionBackoff` instance API landed in B3 task 1.)
+2. TwinCAT: field + `EnsureConnectedUnderGateAsync`/`EnsureConnectedAsync` bypass param + probe wiring.
+3. FOCAS: field + `EnsureConnectedAsync` gate + probe bypass.
+4. AbCip: field + `EnsureTagRuntimeAsync` create-gate + probe wiring.
+5. Cross-reference comment in `S7Driver.EnsureConnectedAsync` pointing R2-01 at the primitive (comment only — no S7 behavior change here).
+
+**Tests (all fake-client — TwinCAT/FOCAS have no fixture; AbCip's is optional):**
+- Per driver: `DeadDevice_ConnectAttemptsFollowBackoffSchedule` — failing fake connect; N rapid data calls; assert the fake's connect/create invocation count matches the schedule (1 attempt + fail-fasts inside the window), not N.
+- Per driver: `ProbeBypassesBackoff_AndSuccessResets` — device recovers; assert the next probe tick connects despite an open window and the following data call succeeds immediately (no residual delay).
+- AbCip: `CacheHit_NeverConsultsBackoff` (healthy runtime reads unaffected).
+
+**Effort:** M. **Risk:** Low-Medium — the failure mode to guard is *delayed recovery*; the probe-bypass + instant-reset design plus the reset tests pin it. Fail-fast inside the window changes dead-device error latency (faster Bad, less wire noise) — intended.
+
+---
+
+# Sub-batch B5 — `ResolveHost` via the resolver: per-host breaker isolation (CONV-4, Medium)
+
+*Independently shippable. No dependencies.*
+
+## CONV-4 — `ResolveHost` mis-keys equipment-tag references in every multi-device driver
+
+**Restatement:** every `ResolveHost` consults only `_tagsByName` (authored names); an equipment-tag reference (raw TagConfig JSON) never matches and falls back to the first device's host — so per-host Polly bulkhead/circuit-breaker keys are wrong for the *primary* authoring model, and a broken device B trips device A's breaker. Now production-live via the CapabilityInvoker dispatch wiring (#10).
+
+**Verification:** CONFIRMED at all five sites — `AbCipDriver.cs:483-488`, `AbLegacyDriver.cs:496-501`, `TwinCATDriver.cs:689-696` (fallback `UnresolvedHostSentinel = ""`), `FocasDriver.cs:1082-1087`, `ModbusDriver.cs:125-131` (per-unit host `BuildSlaveHostName :134`; `ResolveUnitId :474` = per-tag override or driver default). All four multi-device `TDef` records carry `DeviceHostAddress`, and all four equipment parsers populate it from the `deviceHostAddress` key (verified: AbCip `:39/:56`, AbLegacy `:42/:51`, TwinCAT `:30/:38`, FOCAS `:32/:34` — FOCAS coalesces a missing key to `""`). `ModbusEquipmentTagParser` parses **no** `unitId` (grep-confirmed), so Modbus equipment tags currently cannot key per-unit at all. `EquipmentTagRefResolver` (`Core.Abstractions/EquipmentTagRefResolver.cs`) already caches parses (positive and negative) — routing `ResolveHost` through it adds no per-call parse cost after first use.
+
+**Root cause:** `ResolveHost` predates the equipment-tag path and never learned about `_parseRef`.
+
+**Proposed design** (round-1 plan B3, confirmed): route through the resolver, derive the host from the parsed def:
+- AbCip / AbLegacy / TwinCAT / FOCAS: `ResolveHost(ref)` becomes `_resolver.TryResolve(ref, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress) ? def.DeviceHostAddress : ` (first-device / `UnresolvedHostSentinel` / `DriverInstanceId` — each driver keeps its exact current fallback; the empty-string guard covers FOCAS's `?? ""` default so an address-less equipment tag falls back rather than keying an `""` host that TwinCAT alone treats as a documented sentinel).
+- Modbus: `ResolveHost` becomes `_resolver.TryResolve(ref, out var tag) ? BuildSlaveHostName(ResolveUnitId(tag)) : HostName`; and `ModbusEquipmentTagParser` gains an optional `unitId` (byte, absent ⇒ null ⇒ driver-level `_options.UnitId` via `ResolveUnitId`) so multi-unit equipment tags key their own breaker. **Scope note:** `unitId` only — the parser's other gaps (`writable`, `deadband`, address-grammar…) are UNDER-6 and the strictness tiers are **R2-11's**.
+- *Optional resolver sugar* (`ResolveHost(ref, Func)` overload on `EquipmentTagRefResolver`) — skipped: five one-line bodies don't justify a new generic seam; revisit if a sixth pattern appears.
+
+**Implementation steps:**
+1. Rewrite the four multi-device `ResolveHost` bodies (files/lines above).
+2. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs`: parse optional `unitId` into `ModbusTagDefinition.UnitId`; rewrite `ModbusDriver.ResolveHost`.
+3. XML-doc each `ResolveHost`: the key is consumed by per-host resilience (bulkhead/breaker) keying — equipment tags MUST resolve their authored device.
+
+**Tests:**
+- Per multi-device driver (in each `.Tests` project, pattern: two configured devices A+B, an equipment-tag ref whose TagConfig JSON names device B): `ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost` — asserts B (today: A). Plus `ResolveHost_UnknownRef_KeepsCurrentFallback` (regression: fallback string unchanged per driver).
+- FOCAS: equipment ref with no `deviceHostAddress` falls back (not `""`).
+- Modbus: `EquipmentTag_WithUnitId_ResolvesPerUnitHostName` (`host:port/unitN`) + parser round-trip test in the existing `ModbusEquipmentTagParser` suite; absent `unitId` keeps driver-level behavior.
+
+**Effort:** S-M. **Risk:** Low — corrects a mis-key; the visible change is that per-host breaker keys become per-device for equipment tags (strictly the intended semantics). Verify no dashboard/consumer grouped on the old first-device key (the `/hosts` status table keys on `HostConnectivityStatus.HostName`, which already reports per-device — this fix *aligns* the two).
+
+---
+
+# Sub-batch B6 — TwinCAT replay hardening (STAB-16 Medium, STAB-17 Low, STAB-12-compound Medium)
+
+*Independently shippable. No dependencies. Concurrency fixes — high-risk classification. No TC3 fixture exists — the fake-`ITwinCATClient` suite (`TwinCATReconnectReplayTests`) is authoritative.*
+
+## STAB-16 — replay failure is silent to subscribers and never retried while the client stays up
+
+**Restatement:** `ReplayNativeRegistrationsAsync` logs and skips a failed intent (deliberately — one bad symbol must not abort recovery of the rest), but the failed registration keeps its dead handle, nothing can push a Bad snapshot (the `OnChange` shape has no quality channel), and nothing retries until the *next client swap*. A transient `AddNotificationAsync` failure (ADS notification-quota blip, symbol mid-redeploy) kills that tag's push feed with only a warning log.
+
+**Verification:** CONFIRMED — catch at `TwinCATDriver.cs:801-807` (log-only; `SwapHandle` success-path-only `:797`); `OnChange` is `Action` building Good-only snapshots in the `SubscribeAsync` closure (`:477-479`); replay is invoked solely from `EnsureConnectedUnderGateAsync :773` (new-client installs only); the probe loop's success path (`:631-651`) never re-examines registrations.
+
+**Root cause:** the replay commit treated per-intent failure as purely a logging concern; the registration's lifecycle has no "dead, needs re-registration" state.
+
+**Proposed design:**
+- **Dead-handle state:** `NativeRegistration` gains `public bool HasLiveHandle => Volatile.Read(ref _handle) is not null;` and `public ITwinCATNotificationHandle? MarkHandleDead() => Interlocked.Exchange(ref _handle, null);`. On replay failure: `reg.MarkHandleDead()` + dispose the returned (already-dead) handle best-effort.
+- **Bad-quality surfacing:** `NativeRegistration` gains a second stored callback `Action OnUnavailable` (built in `SubscribeAsync` beside `OnChange`, closing over the same `(handle, reference)`): `() => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow)))`. The replay-failure catch invokes it (defensively wrapped) and degrades `_health` to `Degraded` preserving `LastSuccessfulRead`. Subscribers see the tag go Bad instead of frozen-Good-stale.
+- **Probe-tick retry:** new `RetryDeadRegistrationsAsync(DeviceState device, CancellationToken ct)`: under `ConnectGate`, if `device.Client is { IsConnected: true } client`, for each `reg` in `device.NativeRegistrations.Values` with `!reg.HasLiveHandle`, attempt `AddNotificationAsync` → `SwapHandle` → **ownership re-check (shared with STAB-17 below)**; on success, count + log (recovery of the value itself arrives via ADS's initial-notification push on register — same mechanism initial subscribe relies on). Called from `ProbeLoopAsync` after a successful probe (`success == true`, after `:640`'s block). Failures just stay dead for the next tick — bounded retry cadence = probe interval, no extra timers.
+- *Alternative — retry inside a dedicated timer:* rejected; the probe loop is the existing per-device liveness cadence and already holds the right gate discipline.
+
+## STAB-17 — unsubscribe racing a replay leaks a live ADS notification
+
+**Restatement:** `NativeRegistration.Dispose` runs gate-free (registry remove + handle dispose). A concurrent replay has already snapshotted the intents (`:786`) and may complete `AddNotificationAsync` *after* the dispose; `SwapHandle` installs the fresh handle into a registration nobody owns — the ADS notification stays live on the new client for its lifetime.
+
+**Verification:** CONFIRMED (`:607-613` vs `:786-798`). Narrow window, bounded blast radius (one orphan per race), but a genuine native-resource leak on the PLC side.
+
+**Proposed design** (the report's recommended shape): **post-swap ownership re-check** — in `ReplayNativeRegistrationsAsync` (and the new `RetryDeadRegistrationsAsync`), after `reg.SwapHandle(newHandle)`: `if (!device.NativeRegistrations.ContainsKey(reg.Id)) { var orphan = reg.MarkHandleDead(); try { orphan?.Dispose(); } catch { } }` — the registration was unsubscribed mid-replay; retract the fresh notification immediately. *Alternative — take `ConnectGate` in `Dispose`:* rejected: `Dispose` is called from `UnsubscribeAsync` (server-facing, should stay non-blocking) and would wait behind a wedged connect — exactly the STAB-12 hazard class this same sub-batch is shrinking.
+
+## STAB-12 (compounded part) — `RecycleClientAsync` waits the gate uncancellably
+
+**Restatement:** the probe loop's `RecycleClientAsync` waits `ConnectGate` with `CancellationToken.None`, so a connect wedged under the gate (TwinCAT's blocking SDK `Connect`, `AdsTwinCATClient.cs:73-80` — the un-fixed root of STAB-12, carried) now also wedges the probe loop beyond cancellation.
+
+**Verification:** CONFIRMED at `TwinCATDriver.cs:826` (drifted from cited `:835`). Both call sites (`:640`, `:648`) are in `ProbeLoopAsync` and hold `ct`.
+
+**Proposed design:** `RecycleClientAsync(DeviceState device, CancellationToken ct)` — pass the probe token into `ConnectGate.WaitAsync(ct)`. Both call sites pass the loop `ct`; the `:648` site sits inside a `catch` block, so wrap its await in `try { … } catch (OperationCanceledException) { break-equivalent }` (return from the loop body iteration) so a shutdown mid-recycle exits the probe task cleanly instead of faulting it. The *root* blocking-`Connect` fix (`AdsTwinCATClient`) stays carried under STAB-12 — out of this batch.
+
+## Implementation steps
+
+1. `TwinCATDriver.cs` `NativeRegistration` (`:558-614`): add `OnUnavailable` ctor param + property, `HasLiveHandle`, `MarkHandleDead`.
+2. `SubscribeAsync` (`:474-479`): build + pass the `OnUnavailable` closure.
+3. `ReplayNativeRegistrationsAsync` (`:784-814`): failure catch → `MarkHandleDead` + `OnUnavailable` + `_health = Degraded(...)`; success path → post-swap ownership re-check (STAB-17); keep the per-intent isolation and tally log.
+4. New `RetryDeadRegistrationsAsync`; call from `ProbeLoopAsync` after a successful probe.
+5. `RecycleClientAsync` signature + both call sites + the in-catch OCE wrap.
+
+## Tests (extend `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs` — fake client authoritative)
+
+- `ReplayFailure_EmitsBadQuality_AndDegradesHealth` — fake client fails `AddNotificationAsync` for one symbol during replay; assert an `OnDataChange` with `BadCommunicationError` fired for that reference, health `Degraded` (`LastSuccessfulRead` preserved), sibling intents replayed.
+- `ReplayFailure_RetriedOnNextSuccessfulProbeTick` — after the failed replay, drive a probe tick with the fake now accepting registrations; assert `AddNotificationAsync` re-invoked with the identical intent, a subsequent fake push reaches `OnDataChange` with Good, and no duplicate registration exists.
+- `UnsubscribeDuringReplay_DisposesFreshHandle_NoOrphan` — fake client blocks `AddNotificationAsync` on a test-controlled gate; start replay, `UnsubscribeAsync` mid-flight, release the gate; assert the freshly-created handle was disposed and the fake's live-notification count is zero (fake tracks adds/disposes).
+- `RecycleClient_HonorsCancellation` — wedge the gate (hold `ConnectGate` from the test), cancel the probe token; assert `RecycleClientAsync` throws/returns promptly and the probe task completes (no uncancellable hang; bound with `WaitAsync(seconds)` so a regression fails rather than hangs the suite).
+- Regression: the existing 4 replay tests stay green.
+
+**Effort:** M. **Risk:** Medium — concurrency around `ConnectGate`/`Interlocked` handle state; mitigated by the fake-client suite and by keeping every new mutation inside the existing gate discipline (retry under gate; ownership re-check is lock-free `Interlocked` + registry read, matching the existing swap idiom).
+
+---
+
+# Task breakdown
+
+Rules: tasks are bite-sized (≤ ~5 min of implementation each — TDD steps split test-writing from implementation); **sub-batch boundaries are PR boundaries** (one branch + one merge per sub-batch: `fix/r2-09-b1-modbus-desync`, `fix/r2-09-b2-abcip-focas-locks`, `fix/r2-09-b3-pollengine-v2`, `fix/r2-09-b4-connect-backoff`, `fix/r2-09-b5-resolvehost`, `fix/r2-09-b6-twincat-replay`). B1/B2/B5/B6 are mutually independent and parallelizable; B4 depends on B3.1-3.2 only; within a sub-batch, tasks are sequential unless noted. Parallel implementers on this shared tree must worktree-isolate (house rule).
+
+## B1 — Modbus fatal-classification  *(sub-batch boundary — PR 1)*
+
+### Task B1.1 — Failing transport-desync tests
+**Classification:** test-first (low-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B2.*, B3.*, B5.*, B6.*
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTcpReconnectTests.cs`
+1. Extend `FlakeyModbusServer` with scriptable per-request behaviors: stall (no reply), wrong-TxId reply, truncated header.
+2. Add the four tests from the B1 design (`…ResponseTimeout_TearsDownAndReconnects`, `…TxIdMismatch…`, `…TruncatedHeader…`, `…CallerCancellation_DoesNotTearDown`).
+3. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTcpReconnectTests"` → **FAIL** (first three; caller-cancel may pass — keep as regression pin).
+4. Commit: `test(modbus): failing repro for STAB-3 timeout/framing stream desync`.
+
+### Task B1.2 — Teardown-before-propagate in `SendOnceAsync`
+**Classification:** transport fault-path (medium-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs` (new), `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
+1. Add `ModbusTransportDesyncException : IOException`.
+2. Wrap `SendOnceAsync` body: OCE `when (!ct.IsCancellationRequested)` + `InvalidDataException` ⇒ `await TearDownAsync()` ⇒ throw desync exception.
+3. Same filter as B1.1 step 3 → **PASS** (all four).
+4. Commit: `fix(modbus): classify per-op timeout + framing violations connection-fatal (05/STAB-3)`.
+
+### Task B1.3 — Full Modbus suite + PR
+**Classification:** verification
+**Estimated implement time:** 3 min
+**Parallelizable with:** —
+**Files:** —
+1. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests` and `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests` → green.
+2. Optional live gate: modbus fixture + `exception_injector` non-responding-unit scenario.
+3. Merge PR 1.
+
+## B2 — AbCip lock + FOCAS caches  *(sub-batch boundary — PR 2)*
+
+### Task B2.1 — Failing AbCip concurrency test
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B1.*, B2.4, B3.*, B5.*, B6.*
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs` (new), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs`
+1. Add a reentrancy-violation counter to `FakeAbCipTag` (Interlocked in-op depth; record violation when >1).
+2. Write the hammer test (concurrent Read + Write + group-read on one tag; assert zero violations) — mirror `AbLegacyRuntimeConcurrencyTests`.
+3. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~AbCipRuntimeConcurrencyTests"` → **FAIL**.
+4. Commit: `test(abcip): failing repro for STAB-4 unserialized shared-handle ops`.
+
+### Task B2.2 — `GetRuntimeLock` on AbCip `DeviceState`
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 3 min
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (DeviceState `:1187-1281`)
+1. Port `_runtimeLocks` + `GetRuntimeLock` + dispose-in-`DisposeHandles` from `AbLegacyDriver.cs:773-787, 841-842`, with the hazard doc-comment.
+2. Commit: `feat(abcip): per-runtime operation lock on DeviceState (AbLegacy pattern)`.
+
+### Task B2.3 — Wrap the three AbCip op bodies
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`:544-575`, `:614-637`, `:708-726`)
+1. `WaitAsync(ct)`/`finally Release` around ReadSingle / ReadGroup / Write critical sections.
+2. B2.1's filter → **PASS**; then full AbCip suite (`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`) → green (RMW suite proves no nesting/deadlock).
+3. Commit: `fix(abcip): serialize Read/GetStatus/Decode + Encode/Write/GetStatus per runtime (05/STAB-4)`.
+
+### Task B2.4 — Failing FOCAS cache-concurrency test
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B2.1-B2.3 (different driver)
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeConcurrencyTests.cs` (new)
+1. Stress test: writer thread grows/updates the four `DeviceState` caches (force resizes) vs concurrent `TryGetValue` readers; assert no exception over N iterations.
+2. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~FocasFixedTreeConcurrencyTests"` → **FAIL** (flaky-fail is expected on UB — loop iterations high enough to be deterministic in practice).
+3. Commit: `test(focas): failing repro for STAB-5 cross-thread Dictionary mutation`.
+
+### Task B2.5 — Convert the four FOCAS caches
+**Classification:** concurrency (high-risk, type-only)
+**Estimated implement time:** 3 min
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (`:1184-1194`)
+1. Four fields → `ConcurrentDictionary<,>`; initializers adjusted. No call-site edits.
+2. B2.4's filter → **PASS**; full FOCAS suite green.
+3. Commit: `fix(focas): ConcurrentDictionary for fixed-tree caches (05/STAB-5)`. Merge PR 2.
+
+## B3 — PollGroupEngine v2 + S7 fork retirement  *(sub-batch boundary — PR 3)*
+
+### Task B3.1 — `ConnectionBackoff` primitive + tests
+**Classification:** shared primitive (medium-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B1.*, B2.*, B5.*, B6.*
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs` (new)
+1. Write `ConnectionBackoffTests` first (schedule parity with `S7Driver.ComputeBackoffDelay` — port the S7 asserts; `ShouldAttempt`/`RecordFailure`/`RecordSuccess` window + instant-reset semantics). `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~ConnectionBackoffTests"` → **FAIL** (doesn't compile → add type) → implement → **PASS**.
+2. Commit: `feat(core): ConnectionBackoff primitive — shared capped-exponential backoff + attempt throttle (05/STAB-8; seam for R2-01 S7 wiring)`.
+
+### Task B3.2 — Engine backoff + OCE hardening (tests first)
+**Classification:** concurrency (high-risk — shared loop semantics)
+**Estimated implement time:** 5 min (tests) + 5 min (impl)
+**Parallelizable with:** — (after B3.1)
+**Files:** `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/PollGroupEngineTests.cs`, `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`
+1. Add `Backoff_SustainedReaderFailures_StretchesCadenceToCap`, `Backoff_SuccessResetsToInterval`, `ReaderOperationCanceled_WithoutCallerCancellation_TreatedAsFailureNotTeardown`, `CallerCancellation_StillExitsPromptly`. Filter `FullyQualifiedName~PollGroupEngineTests` → **FAIL**.
+2. Implement: `backoffCap` ctor param, `consecutiveFailures`, `ConnectionBackoff.ComputeDelay`, `when (ct.IsCancellationRequested)` on the two `PollOnceAsync` OCE catches (`:122, :139`). → **PASS** (plus all existing engine tests).
+3. Commit: `feat(core): PollGroupEngine v2 — failure backoff + caller-token-filtered OCE exit (CONV-1/STAB-8; STAB-14-class engine guard)`.
+
+### Task B3.3 — `onError` + backoffCap wiring: Modbus, AbCip, AbLegacy
+**Classification:** driver wiring (medium-risk)
+**Estimated implement time:** 5 min per driver (tests-first each)
+**Parallelizable with:** B3.4
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs:115`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:133`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65` + one test per driver's `.Tests` project
+1. Per driver: failing test `PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead_NeverDowngradesFaulted` (e.g. `--filter "FullyQualifiedName~PollErrorHealthTests"` per project) → **FAIL**.
+2. Add `HandlePollError(Exception)` (driver's own health idiom) + pass `onError:` and `backoffCap: TimeSpan.FromSeconds(30)`. → **PASS**.
+3. Commit per driver or one commit: `fix(drivers): wire PollGroupEngine onError→health + 30s poll backoff (05/STAB-9) [modbus,abcip,ablegacy]`.
+
+### Task B3.4 — `onError` + backoffCap wiring: TwinCAT, FOCAS
+**Classification:** driver wiring (medium-risk)
+**Estimated implement time:** 5 min per driver
+**Parallelizable with:** B3.3
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:69`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs:72` + tests
+1. Same TDD shape as B3.3.
+2. Commit: `fix(drivers): wire PollGroupEngine onError→health + 30s poll backoff (05/STAB-9) [twincat,focas]`.
+
+### Task B3.5 — S7 migration guard tests (failing)
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PollEngineMigrationTests.cs` (new)
+1. Write `ArrayTag_UnchangedBetweenPolls_DoesNotRefire` (PERF-3), `RapidSubscribeUnsubscribe_NoObjectDisposedException` (STAB-6-S7), `SustainedPollFailure_DegradesHealth_WithBackoff` against the fake `IS7PlcFactory` seam.
+2. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7PollEngineMigrationTests"` → **FAIL** (array test fails on the fork's `Equals` diff; unsub test fails/flakes on the disposed CTS).
+3. Commit: `test(s7): failing guards for the poll-fork retirement (PERF-3, STAB-6)`.
+
+### Task B3.6 — Retire the S7 fork onto the engine
+**Classification:** concurrency (high-risk — subscription surface rewrite)
+**Estimated implement time:** 3 × 5 min (ctor+subscribe/unsubscribe swap; fork deletion + teardown swap; `ComputeBackoffDelay` relocation)
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (`:59`, `:269-270`, `:1328-1499`, `:1605-1606`), S7 backoff test relocation into `ConnectionBackoffTests`
+1. Construct `_poll = new PollGroupEngine(reader: ReadAsync, onChange: …, minInterval: 100ms, onError: HandlePollError, backoffCap: 30s)`; route `SubscribeAsync`/`UnsubscribeAsync` through it; delete `PollLoopAsync`/`PollOnceAsync`/`HandlePollFailure`/`SubscriptionState`/`S7SubscriptionHandle`/`_subscriptions`/`PollBackoffCap`/`PollFailureHealthThreshold`/`ComputeBackoffDelay`; swap both teardown drains for `_poll.DisposeAsync()`.
+2. B3.5's filter → **PASS**; full S7 suite (`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests`) → green, incl. `S7DriverReconnectTests` + `S7DiscoveryAndSubscribeTests`.
+3. Commit: `refactor(s7): retire the bespoke poll fork onto PollGroupEngine v2 (CONV-1; fixes PERF-3 + STAB-6-S7; deletes the fork's bare-OCE poll-death — cross-ref plan R2-01)`.
+
+### Task B3.7 — Cross-solution verification + PR
+**Classification:** verification
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+1. `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests` + all five driver unit suites → green.
+2. Live gate (recommended for the S7 leg): s7 fixture bounce recipe (`lmxopcua-fix down s7 && lmxopcua-fix up s7 s7_1500`) with the env-gated `S7_1500ReconnectTests`.
+3. Merge PR 3. Note in the PR body: R2-01 sequencing paragraph from the B3 design.
+
+## B4 — Connect-attempt throttles  *(sub-batch boundary — PR 4; blocked by B3.1-B3.2 only)*
+
+### Task B4.1 — TwinCAT connect throttle (tests first)
+**Classification:** concurrency (high-risk — connect-path gating)
+**Estimated implement time:** 5 min (tests) + 5 min (impl)
+**Parallelizable with:** B4.2, B4.3
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` (`DeviceState`, `EnsureConnectedAsync :706`, `EnsureConnectedUnderGateAsync :731`, probe `:631`), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs` (new)
+1. Failing tests: `DeadDevice_ConnectAttemptsFollowBackoffSchedule`, `ProbeBypassesBackoff_AndSuccessResets` (filter `FullyQualifiedName~TwinCATConnectBackoffTests`) → **FAIL** → implement per B4 design → **PASS**.
+2. Commit: `fix(twincat): per-device connect-attempt backoff, probe-driven recovery (05/STAB-8)`.
+
+### Task B4.2 — FOCAS connect throttle (tests first)
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min + 5 min
+**Parallelizable with:** B4.1, B4.3
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (`DeviceState`, `EnsureConnectedAsync :1089`, probe loop), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs` (new)
+1. Same TDD shape (fake `IFocasClientFactory` counting connects). FAIL → implement → PASS.
+2. Commit: `fix(focas): per-device connect-attempt backoff across the three loops (05/STAB-8)`.
+
+### Task B4.3 — AbCip runtime-create throttle (tests first)
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min + 5 min
+**Parallelizable with:** B4.1, B4.2
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`DeviceState`, `EnsureTagRuntimeAsync :852`, probe loop), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectBackoffTests.cs` (new)
+1. Tests: schedule on the create path, `CacheHit_NeverConsultsBackoff`, probe bypass + reset. FAIL → implement → PASS.
+2. Commit: `fix(abcip): throttle evict-recreate Forward-Open attempts per device (05/STAB-8)`.
+
+### Task B4.4 — S7 seam note + PR
+**Classification:** docs/verification
+**Estimated implement time:** 3 min
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (comment on `EnsureConnectedAsync`)
+1. Comment pointing R2-01 at `ConnectionBackoff` for the S7 attempt-throttle. Full three driver suites green. Merge PR 4.
+
+## B5 — `ResolveHost` via resolver  *(sub-batch boundary — PR 5)*
+
+### Task B5.1 — Failing `ResolveHost` tests (4 multi-device drivers)
+**Classification:** correctness (medium-risk)
+**Estimated implement time:** 2 × 5 min
+**Parallelizable with:** B1.*, B2.*, B3.*, B6.*
+**Files:** one test file (or additions) per `.Tests` project: AbCip, AbLegacy, TwinCAT, FOCAS
+1. Per driver: `ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost` (two devices, equipment ref naming device B → expect B) + `ResolveHost_UnknownRef_KeepsCurrentFallback`; FOCAS adds the empty-`deviceHostAddress` fallback case. Suggested shared name: `--filter "FullyQualifiedName~ResolveHostTests"` per project → **FAIL** (4×).
+2. Commit: `test(drivers): failing repro for CONV-4 equipment-tag ResolveHost mis-keying`.
+
+### Task B5.2 — Rewrite the four `ResolveHost` bodies
+**Classification:** correctness (medium-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B5.3
+**Files:** `AbCipDriver.cs:483-488`, `AbLegacyDriver.cs:496-501`, `TwinCATDriver.cs:689-696`, `FocasDriver.cs:1082-1087`
+1. Route through `_resolver.TryResolve` + empty-host guard; keep per-driver fallbacks. B5.1 filters → **PASS**.
+2. Commit: `fix(drivers): ResolveHost resolves equipment-tag refs via EquipmentTagRefResolver — per-host breaker isolation (CONV-4)`.
+
+### Task B5.3 — Modbus `unitId` + `ResolveHost`
+**Classification:** correctness (medium-risk)
+**Estimated implement time:** 5 min (test) + 5 min (impl)
+**Parallelizable with:** B5.2
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs:125-131`, Modbus parser + driver tests
+1. Failing tests: parser `unitId` round-trip; `EquipmentTag_WithUnitId_ResolvesPerUnitHostName`; absent-`unitId` regression. → **FAIL** → implement → **PASS**. (Scope: `unitId` ONLY — R2-11 owns parser strictness/parity.)
+2. Commit: `fix(modbus): equipment tags carry unitId; ResolveHost keys per-unit via the resolver (CONV-4)`.
+
+### Task B5.4 — Suites + PR
+**Classification:** verification
+**Estimated implement time:** 3 min
+1. All five driver unit suites green. Merge PR 5.
+
+## B6 — TwinCAT replay hardening  *(sub-batch boundary — PR 6)*
+
+### Task B6.1 — Failing test: replay failure surfaces Bad + degrades health
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** B1.*, B2.*, B3.*, B5.*
+**Files:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs`
+1. `ReplayFailure_EmitsBadQuality_AndDegradesHealth` per B6 design. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests --filter "FullyQualifiedName~TwinCATReconnectReplayTests"` → **FAIL**.
+2. Commit: `test(twincat): failing repro for STAB-16 silent replay failure`.
+
+### Task B6.2 — Dead-handle state + `OnUnavailable` + replay-catch surfacing
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 2 × 5 min
+**Parallelizable with:** —
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` (`NativeRegistration :558-614`, `SubscribeAsync :474-479`, `ReplayNativeRegistrationsAsync :784-814`)
+1. `HasLiveHandle`/`MarkHandleDead`/`OnUnavailable` + closure in `SubscribeAsync` + failure-catch: mark dead, emit Bad, degrade health.
+2. B6.1's filter → **PASS**. Commit: `fix(twincat): replay failure marks the registration dead, publishes Bad quality, degrades health (05/STAB-16)`.
+
+### Task B6.3 — Probe-tick retry of dead registrations (tests first)
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min + 5 min
+**Parallelizable with:** —
+**Files:** same driver file (`ProbeLoopAsync :622-656`, new `RetryDeadRegistrationsAsync`), same test file
+1. Failing `ReplayFailure_RetriedOnNextSuccessfulProbeTick` → implement `RetryDeadRegistrationsAsync` (under `ConnectGate`, re-register `!HasLiveHandle` intents, tally log) + probe-success call site → **PASS**.
+2. Commit: `fix(twincat): retry failed replay intents on the next successful probe tick (05/STAB-16)`.
+
+### Task B6.4 — STAB-17 ownership re-check (tests first)
+**Classification:** concurrency (high-risk)
+**Estimated implement time:** 5 min + 5 min
+**Parallelizable with:** —
+**Files:** same driver file (`ReplayNativeRegistrationsAsync` success path + `RetryDeadRegistrationsAsync`), same test file (+ fake-client live-notification tracking)
+1. Failing `UnsubscribeDuringReplay_DisposesFreshHandle_NoOrphan` (gate-blocked fake `AddNotificationAsync`) → implement the post-`SwapHandle` `NativeRegistrations.ContainsKey` re-check + orphan dispose in both replay paths → **PASS**.
+2. Commit: `fix(twincat): retract the fresh ADS notification when unsubscribe raced the replay (05/STAB-17)`.
+
+### Task B6.5 — Cancellable `RecycleClientAsync` (tests first)
+**Classification:** fault-path (medium-risk)
+**Estimated implement time:** 5 min
+**Parallelizable with:** —
+**Files:** same driver file (`RecycleClientAsync :824-840`, probe call sites `:640, :648`), same test file
+1. Failing `RecycleClient_HonorsCancellation` (held gate + cancelled token; bounded wait so regression fails, not hangs) → thread `ct` through `ConnectGate.WaitAsync` + OCE-wrap the in-catch call site → **PASS**.
+2. Commit: `fix(twincat): probe recycle honors cancellation — no uncancellable gate wait (05/STAB-12 compounded part)`.
+
+### Task B6.6 — Full TwinCAT suite + PR
+**Classification:** verification
+**Estimated implement time:** 3 min
+1. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests` → green (incl. the pre-existing 4 replay tests). No TC3 fixture — this suite is the authoritative gate (UNDER-7 noted).
+2. Merge PR 6.
+
+---
+
+# Sequencing & effort roll-up
+
+| PR | Sub-batch | Findings | Effort | Risk | Depends on | Live gate |
+|---|---|---|---|---|---|---|
+| 1 | B1 Modbus fatal-classification | STAB-3 | S | Low-Med | — | modbus fixture (+`exception_injector`), optional |
+| 2 | B2 AbCip lock + FOCAS caches | STAB-4, STAB-5 | S-M | Med (concurrency) | — | abcip fixture optional; FOCAS unit-authoritative |
+| 3 | B3 PollGroupEngine v2 + S7 fork retirement (+`ConnectionBackoff`) | STAB-9, CONV-1, PERF-3, STAB-6-S7, STAB-8-poll | M | Med-High | — (coordinate with R2-01) | s7 fixture bounce, recommended |
+| 4 | B4 connect-attempt throttles (TwinCAT/FOCAS/AbCip) | STAB-8-fleet | M | Low-Med | B3.1-B3.2 | none needed (fake-client) |
+| 5 | B5 `ResolveHost` via resolver (+Modbus `unitId`) | CONV-4 | S-M | Low | — | none needed |
+| 6 | B6 TwinCAT replay hardening | STAB-16, STAB-17, STAB-12-part | M | Med (concurrency) | — | none exists — unit-authoritative |
+
+**Overall effort:** ≈ 5.5–7 dev-days across 6 independently shippable PRs (B1/B2/B5/B6 parallelizable from day one; B4 unblocks after B3's first two tasks).
+
+**Explicit hand-offs:** `ConnectionBackoff` → **R2-01** (S7 `EnsureConnectedAsync` throttle + the STAB-14/15 wrapper/classification fixes; B3.6's fork deletion removes the poll-loop half of STAB-14's blast surface in whichever order the two plans land). Parser strictness / `ReadEnum` / remaining equipment-parser parity → **R2-11**. Carried and untouched here: STAB-10, STAB-11, full STAB-12 (FOCAS close-PDU + TwinCAT blocking `Connect`), CONV-3 (`ILoggerFactory` — worth an early ride-along in any of these PRs per the report, but owned elsewhere), PERF-1/2, CONV-5 idioms.
diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
new file mode 100644
index 00000000..6292c0d7
--- /dev/null
+++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
@@ -0,0 +1,41 @@
+{
+  "planPath": "archreview/plans/R2-09-driver-fleet-batch-plan.md",
+  "lastUpdated": "2026-07-12",
+  "notes": "Sub-batches B1/B2/B5/B6 are mutually independent PRs; B4 depends on B3 tasks B3.1-B3.2. Ordering inside a sub-batch is encoded via blockedBy. Concurrency tasks are high-risk (see plan Classification fields).",
+  "tasks": [
+    { "id": "B1.1", "subject": "B1: failing Modbus transport-desync tests (stall / wrong TxId / truncated header / caller-cancel pin) in ModbusTcpReconnectTests", "status": "pending", "blockedBy": [] },
+    { "id": "B1.2", "subject": "B1: ModbusTransportDesyncException + teardown-before-propagate in SendOnceAsync (timeout-OCE + InvalidDataException) — STAB-3", "status": "pending", "blockedBy": ["B1.1"] },
+    { "id": "B1.3", "subject": "B1: full Modbus suites green + optional exception_injector live gate + merge PR 1", "status": "pending", "blockedBy": ["B1.2"] },
+
+    { "id": "B2.1", "subject": "B2: failing AbCipRuntimeConcurrencyTests (reentrancy-guarded FakeAbCipTag hammer) — STAB-4 repro", "status": "pending", "blockedBy": [] },
+    { "id": "B2.2", "subject": "B2: port _runtimeLocks + GetRuntimeLock + dispose onto AbCip DeviceState (AbLegacy pattern)", "status": "pending", "blockedBy": ["B2.1"] },
+    { "id": "B2.3", "subject": "B2: wrap ReadSingleAsync/ReadGroupAsync/WriteAsync critical sections in the per-runtime lock; AbCip suite green", "status": "pending", "blockedBy": ["B2.2"] },
+    { "id": "B2.4", "subject": "B2: failing FocasFixedTreeConcurrencyTests (writer-vs-reader stress on the four DeviceState caches) — STAB-5 repro", "status": "pending", "blockedBy": [] },
+    { "id": "B2.5", "subject": "B2: convert the four FOCAS caches to ConcurrentDictionary; FOCAS suite green; merge PR 2", "status": "pending", "blockedBy": ["B2.3", "B2.4"] },
+
+    { "id": "B3.1", "subject": "B3: ConnectionBackoff primitive in Core.Abstractions (static ComputeDelay ported from S7 + instance attempt-throttle) + ConnectionBackoffTests — STAB-8 seam, hand-off to R2-01", "status": "pending", "blockedBy": [] },
+    { "id": "B3.2", "subject": "B3: PollGroupEngine v2 — backoffCap + consecutiveFailures + caller-token-filtered OCE catches (STAB-14-class engine guard) + engine tests FAIL->PASS", "status": "pending", "blockedBy": ["B3.1"] },
+    { "id": "B3.3", "subject": "B3: wire onError->health + 30s backoffCap into Modbus/AbCip/AbLegacy engine ctors (tests first per driver) — STAB-9", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B3.4", "subject": "B3: wire onError->health + 30s backoffCap into TwinCAT/FOCAS engine ctors (tests first per driver) — STAB-9", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B3.5", "subject": "B3: failing S7PollEngineMigrationTests (array-cadence PERF-3, rapid sub/unsub STAB-6, sustained-failure health+backoff)", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B3.6", "subject": "B3: retire the S7 poll fork onto PollGroupEngine (delete PollLoopAsync/PollOnceAsync/HandlePollFailure/SubscriptionState; ComputeBackoffDelay -> ConnectionBackoff) — CONV-1; cross-ref R2-01", "status": "pending", "blockedBy": ["B3.3", "B3.4", "B3.5"] },
+    { "id": "B3.7", "subject": "B3: engine + all five driver suites green; s7 fixture bounce live gate; merge PR 3", "status": "pending", "blockedBy": ["B3.6"] },
+
+    { "id": "B4.1", "subject": "B4: TwinCAT per-device connect-attempt backoff (data-path gate, probe bypass + instant reset) tests FAIL->PASS — STAB-8", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B4.2", "subject": "B4: FOCAS per-device connect-attempt backoff across the three loops, tests FAIL->PASS — STAB-8", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B4.3", "subject": "B4: AbCip evict-recreate (Forward Open) create-path throttle, cache-hit unaffected, tests FAIL->PASS — STAB-8", "status": "pending", "blockedBy": ["B3.2"] },
+    { "id": "B4.4", "subject": "B4: S7 seam comment pointing R2-01 at ConnectionBackoff; suites green; merge PR 4", "status": "pending", "blockedBy": ["B4.1", "B4.2", "B4.3"] },
+
+    { "id": "B5.1", "subject": "B5: failing ResolveHost tests in AbCip/AbLegacy/TwinCAT/FOCAS (equipment ref for device B must key B; fallback regression pins) — CONV-4 repro", "status": "pending", "blockedBy": [] },
+    { "id": "B5.2", "subject": "B5: rewrite the four multi-device ResolveHost bodies via EquipmentTagRefResolver + empty-host guard", "status": "pending", "blockedBy": ["B5.1"] },
+    { "id": "B5.3", "subject": "B5: Modbus — optional unitId in ModbusEquipmentTagParser + ResolveHost via resolver (per-unit breaker key); scope-note vs R2-11", "status": "pending", "blockedBy": ["B5.1"] },
+    { "id": "B5.4", "subject": "B5: all five driver suites green; merge PR 5", "status": "pending", "blockedBy": ["B5.2", "B5.3"] },
+
+    { "id": "B6.1", "subject": "B6: failing ReplayFailure_EmitsBadQuality_AndDegradesHealth in TwinCATReconnectReplayTests — STAB-16 repro", "status": "pending", "blockedBy": [] },
+    { "id": "B6.2", "subject": "B6: NativeRegistration HasLiveHandle/MarkHandleDead/OnUnavailable + replay-catch Bad-quality emit + health degrade — STAB-16", "status": "pending", "blockedBy": ["B6.1"] },
+    { "id": "B6.3", "subject": "B6: RetryDeadRegistrationsAsync on successful probe tick (under ConnectGate), tests FAIL->PASS — STAB-16", "status": "pending", "blockedBy": ["B6.2"] },
+    { "id": "B6.4", "subject": "B6: post-SwapHandle ownership re-check retracts the fresh ADS notification when unsubscribe raced the replay, tests FAIL->PASS — STAB-17", "status": "pending", "blockedBy": ["B6.3"] },
+    { "id": "B6.5", "subject": "B6: RecycleClientAsync takes the probe ct (no CancellationToken.None gate wait) + in-catch OCE wrap, tests FAIL->PASS — STAB-12 compounded part", "status": "pending", "blockedBy": ["B6.4"] },
+    { "id": "B6.6", "subject": "B6: full TwinCAT suite green (fake-client authoritative, no TC3 fixture); merge PR 6", "status": "pending", "blockedBy": ["B6.5"] }
+  ]
+}
diff --git a/archreview/plans/R2-10-resilience-observability-plan.md b/archreview/plans/R2-10-resilience-observability-plan.md
new file mode 100644
index 00000000..9d552d58
--- /dev/null
+++ b/archreview/plans/R2-10-resilience-observability-plan.md
@@ -0,0 +1,193 @@
+# Design + Implementation Plan — R2-10 Resilience Observability: an operator-facing reader for `DriverResilienceStatusTracker`
+
+> **Status (2026-07-12):** PLANNED — not started.
+
+- **Source reports:** `archreview/03-server-runtime.md` (U10) · `archreview/04-adminui.md` (U-5) · `archreview/00-OVERALL.md` prioritized action list **item #10** ("Resilience observability: a `/hosts` reader for `DriverResilienceStatusTracker` (Stream E.2/E.3)" — 03 U10 + 04 U-5, Medium, "operators currently blind").
+- **History:** `archreview/plans/FOLLOWUP-10-resilience-dispatch-gap.md` — #10 (`bacea1a4`) wired `CapabilityInvoker` into all 6 production dispatch sites; #13 (`75403caa`) made AdminUI-authored per-instance `ResilienceConfig` overrides live. The Phase 6.1 "Stream E.2" (snapshot-persisting HostedService) and "E.3" (SignalR push + Blazor `/hosts` panel) readers were never built; the interim mitigation was retry/breaker structured logging in `DriverResiliencePipelineBuilder` (added with #10).
+- **Plan verified against tree at:** `f6eaa267` (master, clean) — every anchor below was opened and re-read at this commit.
+- **Scope:** `Core/Resilience` (tracker + pipeline builder + invoker — additive only), `Commons/Messages/Drivers` (one new message), `Host/Drivers` (one new publisher hosted service + DI), `AdminUI/Hubs` (one new store + one new bridge actor), `AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor` (one new section).
+
+## Verification summary
+
+Every claim in U10/U-5 was re-verified at `f6eaa267`. **All confirmed — none stale.**
+
+- **The tracker has no reader.** `grep -rn "DriverResilienceStatusTracker" src --include="*.cs"` matches only `Core/Resilience/{DriverResilienceStatusTracker, DriverResiliencePipelineBuilder, CapabilityInvoker, DriverCapabilityInvokerFactory}.cs` and `Host/Drivers/DriverFactoryBootstrap.cs` (the DI registration, `:57`). Zero AdminUI/Runtime references. `grep -rn Resilience src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` matches only the authoring surface (`DriverResilienceSection.razor`, `ResilienceFormModel.cs`, the 8 driver pages). The only production consumers of tracker *state* are its own writers.
+- **What the tracker holds** (`DriverResilienceStatusTracker.cs`): a `ConcurrentDictionary<(DriverInstanceId, HostName), ResilienceStatusSnapshot>` — `ConsecutiveFailures`, `LastBreakerOpenUtc`, `LastRecycleUtc`, footprint bytes, `CurrentInFlight`, `LastSampledUtc`. Thread-safe (immutable record values, `AddOrUpdate`); `Snapshot()` returns a copy safe under concurrent writes — ideal for a periodic reader. **Note the key: the tracker aggregates per `(instance, host)`, NOT per capability** — pipelines are per `(instance, host, capability)` (`DriverResiliencePipelineBuilder.PipelineKey`) but all telemetry callbacks fold into the `(instance, host)` counter. The read model below therefore keys per **instance × host** (mirroring the tracker) rather than instance × capability; adding capability granularity would mean re-keying the tracker and every invoker call site — rejected as not the simplest thing (capability is already visible in the #10 log lines when drill-down is needed).
+- **Feeding is real and live:** `CapabilityInvoker` calls `RecordCallStart/Complete` around every wrapped dispatch (`CapabilityInvoker.cs:70/80/94/104…`); `DriverResiliencePipelineBuilder.Build` wires `OnRetry → RecordFailure` (`:131`), `OnOpened → RecordBreakerOpen` (`:155`), `OnClosed → RecordSuccess` (`:165`). Registered singleton + threaded into the builder and invoker factory in `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` (`:57-71`).
+- **Two truthfulness gaps found in the tracker itself** (must fix for a display to be honest):
+  1. **No breaker-close marker.** `RecordBreakerOpen` stamps `LastBreakerOpenUtc` but nothing ever records "closed" — `OnClosed` only calls `RecordSuccess` (resets `ConsecutiveFailures`). "Is the breaker open *now*?" is underivable from the snapshot.
+  2. **`RecordSuccess` has exactly one caller — the breaker's `OnClosed`.** A successful capability call never resets `ConsecutiveFailures`, so after a transient blip with 2 retries the counter reads 2 *forever* (until a full breaker open/close cycle). The tracker's own xmldoc ("Reset … on a successful pipeline execution") shows the intended-but-unwired call from the invoker's success path — another edge of the house "built-but-never-wired" pattern (00-OVERALL theme #1).
+- **Topology determination (where the tracker lives vs where AdminUI renders):** `Host/Program.cs` gates by role: the `hasDriver` block (`:104-262`) calls `AddOtOpcUaDriverFactories()` (`:195`) — **the tracker singleton exists only in driver-role processes**. The `hasAdmin` block mounts the AdminUI (`:278-293`) and spawns the DPS→SignalR bridge actors via `WithOtOpcUaSignalRBridges()` inside the Akka configurator (`:269-273`). In the split-role MAIN cluster (admin-a/admin-b vs driver nodes) tracker and AdminUI are **different processes** — state must travel over the cluster. Even on the fused docker-dev rig (central-1/central-2 both `admin,driver`), `:9200` **round-robins** the two containers while a given `DriverHostActor` child runs on one of them — so a direct in-process tracker read from the Blazor circuit shows the wrong (often empty) node's state. **A cluster transport is mandatory in every deployed topology.**
+- **The precedent to mirror (found, verified end-to-end):** driver-health already makes exactly this trip. `DriverInstanceActor:994` → `AkkaDriverHealthPublisher.Publish` → DPS topic `driver-health` (`DriverHealthChanged` record in `Commons/Messages/Drivers`, `TopicName` const on the contract) → per-admin-node `DriverStatusSignalRBridge` (spawned by `WithOtOpcUaSignalRBridges`, `HubServiceCollectionExtensions.cs:69-72`) → `IDriverStatusSnapshotStore` singleton upsert + `SnapshotChanged` event → `DriverStatusPanel.razor` reads the store **in-process** (`:190-198`), explicitly *not* via a self-targeted SignalR `HubConnection` (the house ban — a server-side circuit behind Traefik cannot dial its own public URL; documented at `IDriverStatusSnapshotStore.cs:28-36` and `DriverStatusPanel.razor:182-189`). This plan mirrors that flow one-for-one.
+- **UI placement facts:** `DriverStatusPanel` is embedded on all 8 driver pages (`Components/Pages/Clusters/Drivers/*DriverPage.razor`) — the **same pages** where `DriverResilienceSection` authors the `ResilienceConfig` overrides that #13 made live. `/hosts` (`Hosts.razor`) shows cluster members + a cluster-scoped driver-health table built by `HostsDriverView.Build(DriverStore.GetAll(), …)` (`:291`).
+- **Test landscape:** tracker/builder/invoker suites exist at `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/` (incl. `DriverResilienceStatusTrackerTests.cs`, `DriverResiliencePipelineBuilderTests.cs`, `InFlightCounterTests.cs`); the DI wiring-guard template is `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs` (pure-DI, no Docker fixture); the store-test template is `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs`. **AdminUI has no bUnit** — razor rendering changes are verified only by the live-`/run` pass (docker-dev `:9200` round-robins central-1/central-2; rebuild BOTH).
+
+---
+
+## Restatement
+
+`DriverResilienceStatusTracker` is registered, fed by every wrapped capability call, retry, and breaker transition — and consumed by nothing. Since #10/#13, retry/breaker/bulkhead behavior is production-live **and operator-authorable** (per-instance `ResilienceConfig` on all 8 driver pages), yet the only way to see a breaker reject calls (`BrokenCircuitException`) or a retry storm is to grep server logs. Operators author live resilience config completely blind. Ship the smallest reader that gives them sight of breaker-open and retry storms, on the page where they author the config.
+
+## Verification
+
+Confirmed at `f6eaa267` — see Verification summary. Anchors: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs` (no non-writer reference anywhere), `DriverResiliencePipelineBuilder.cs:124-170` (the interim log-only surface), `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:57` (singleton registration, driver nodes only).
+
+## Root cause
+
+Phase 6.1 shipped Stream E.1 (the tracker + telemetry hooks) and deferred E.2 (snapshot persistence) / E.3 (push + panel) to "a follow-up visual-review PR" (tracker xmldoc, `:12-16`) that never happened. #10 wired the *writer* side into production and added log lines as an interim reader; the review (03/U10, 04/U-5) now tracks the missing operator surface. Two smaller instances of the same pattern hide inside the tracker itself (no breaker-close marker; `RecordSuccess` unwired from the invoker success path).
+
+## Proposed design
+
+Mirror the driver-health flow end-to-end. Four pieces, smallest-correct at each hop:
+
+**(a) Read model** — `ResilienceStatusSnapshot` gains `LastBreakerClosedUtc` + a derived `IsBreakerOpen` (`LastBreakerOpenUtc` is set and later than any close). One new wire message in Commons:
+
+```csharp
+// Commons/Messages/Drivers/DriverResilienceStatusChanged.cs
+public sealed record DriverResilienceStatusChanged(
+    string DriverInstanceId,
+    string HostName,
+    bool BreakerOpen,
+    int ConsecutiveFailures,
+    int CurrentInFlight,
+    DateTime? LastBreakerOpenUtc,
+    DateTime LastSampledUtc,
+    DateTime PublishedUtc)
+{
+    public const string TopicName = "driver-resilience-status";
+}
+```
+
+Keyed per **(instance, host)** — matches the tracker key (see Verification summary for why not per-capability). No `ClusterId`: `DriverInstanceId` is globally unique and the panel filters on it alone (same as `DriverStatusPanel` today). Tracker truthfulness fixes ride along: `RecordBreakerClose` (called from the builder's `OnClosed` beside the existing `RecordSuccess`) and `RecordSuccess` on the invoker's success paths so `ConsecutiveFailures` is a true consecutive counter (nonzero only during an active storm).
+
+*Known display caveat (documented, accepted):* Polly half-opens the breaker after `BreakDuration` without firing `OnClosed`; `OnClosed` fires only when a probe call succeeds. An idle driver therefore shows "breaker OPEN" until the next call — truthful enough (the next call is at best a probe) and self-corrects on traffic.
+
+**(b) Transport (driver node → admin surface)** — a periodic full-snapshot publisher on driver nodes: `DriverResilienceStatusPublisherService : BackgroundService` in `Host/Drivers/`, ctor `(DriverResilienceStatusTracker, Func, ILogger<…>, TimeSpan? interval = null /* default 5 s */)`. Every tick: `tracker.Snapshot()` → map each triple to a `DriverResilienceStatusChanged` (`PublishedUtc = now`) → `DistributedPubSub.Get(system).Mediator.Tell(new Publish(TopicName, msg))` per entry. Loop body in try/catch (a transient DPS/actor-system hiccup must never kill the service); skip empty snapshots. Publishing the **full set every tick** (not deltas) deliberately self-heals late-subscribing admin nodes — the known redundancy-state "late-subscriber" gotcha — and gives staleness detection for free. Volume is negligible (entries = active instances × hosts, typically < a few dozen, every 5 s). Registered in `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` next to the tracker it reads (driver nodes only, exactly where the tracker exists), with a `TryAddSingleton>` for self-containedness (idempotent with `Program.cs:260`).
+
+Why `Host`, not `Runtime`: Runtime is deliberately **Polly-free** — it references `Core.Abstractions`, not `Core` where the tracker lives (the exact boundary FOLLOWUP-10 discovered the hard way). Host already references Core, Commons, and Akka (`OtOpcUaServerHostedService` resolves the DPS mediator via the same lazy `Func` pattern). No new seam needed.
+
+**Rejected transport alternatives:**
+- *Direct in-process tracker read from the AdminUI/Blazor circuit* — wrong in every deployed topology (split-role MAIN cluster: different process; fused docker-dev: `:9200` round-robin lands circuits on a node that isn't running the driver instance). Rejected.
+- *Persist snapshots to a `DriverInstanceResilienceStatus` table* (the original Stream E.2 shape, still named in the tracker xmldoc) — needs a migration, a poller, and row cleanup; the house idiom has since evolved to DPS → in-memory store, and 04/U-5's own recommendation names that idiom. Rejected; fix the stale xmldoc instead.
+- *Event-driven publish from inside the tracker* (hook every `RecordX`) — would thread an Akka publisher seam into Polly-free/Akka-free Core, and is chattier under a retry storm exactly when the cluster is stressed. Periodic snapshot is simpler and bounded. Rejected.
+- *Extend `DriverHealthChanged`* — mutates an existing wire contract and conflates two feeds with different cadences (event-driven vs periodic). Separate topic = zero blast radius. Rejected.
+- *Pull via `AdminOperationsActor` ask* — request/response through the admin-pinned singleton plus panel-side polling; more moving parts than the established push idiom, and the singleton is one node while the store idiom feeds every admin replica (which the round-robin requires). Rejected.
+
+**(c) Admin surface** — mirror the driver-status pair:
+- `IDriverResilienceStatusStore` + `InMemoryDriverResilienceStatusStore` in `AdminUI/Hubs/`: keyed `(DriverInstanceId, HostName)`, `Upsert`, `GetForInstance(driverInstanceId)` (per-host list), `GetAll()`, `SnapshotChanged` event (capture-then-invoke, same as `InMemoryDriverStatusSnapshotStore`). Registered in `HubServiceCollectionExtensions.AddOtOpcUaDriverStatusServices`.
+- `DriverResilienceStatusBridge` (`ReceiveActor`, `AdminUI/Hubs/`): `PreStart` subscribes to the DPS topic; on message, `store.Upsert(msg)`. Spawned per admin node in `WithOtOpcUaSignalRBridges` beside the other four bridges. **No new SignalR hub** — the only consumer is the server-side panel, which reads the store in-process per the house ban on self-HubConnections; `DriverStatusHub` exists for browser-JS clients and resilience has none. Add a hub later only if an external consumer appears (rejected for v1 as speculative surface).
+
+**(d) UI placement** — extend **`DriverStatusPanel.razor`** (recommended; embedded on all 8 driver pages — the exact pages where `DriverResilienceSection` authors the config, closing the authoring feedback loop). New "Resilience" block under the existing status chips: one row per host with
+- `Breaker OPEN` chip (`chip-bad`) with "opened Xs ago" when `BreakerOpen`, else nothing (healthy is quiet);
+- `N consecutive failures` chip (`chip-warn`) when `ConsecutiveFailures > 0` (the retry-storm signal);
+- `in-flight N` (`chip-idle`) when `CurrentInFlight > 0`;
+- **staleness:** row dims + shows a `stale` chip when `PublishedUtc` is older than 15 s (3× publish interval) — covers driver-node death, where entries freeze; reuses the panel's existing 5 s re-render timer;
+- muted "No resilience activity recorded yet" when the store has no entries for this instance (the tracker only populates once wrapped capability calls run).
+
+Subscription lifecycle copies the existing pattern exactly: inject the store, subscribe `SnapshotChanged` in `OnInitializedAsync`, filter by `DriverInstanceId`, marshal via `InvokeAsync(StateHasChanged)`, unsubscribe first in `DisposeAsync`.
+
+*Rejected UI alternatives:* a `/hosts` column (fleet-wide join of a second store into `HostsDriverView`; more code, and `/hosts` rows are cluster-scoped health — the operator authoring overrides is on the driver page; fine follow-on once the store exists) · a dedicated `/resilience` page (new nav surface for a v1 that needs two chips). The store makes both trivially addable later.
+
+## Implementation steps (exact paths)
+
+1. **Core read-model truthfulness** — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs`: add `LastBreakerClosedUtc` to `ResilienceStatusSnapshot`, derived `IsBreakerOpen`, and `RecordBreakerClose(driverInstanceId, hostName, utcNow)`. Update the stale class xmldoc (`:5-16` still promises the never-built snapshot-persisting HostedService/table).
+2. **Builder close-hook** — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs` (`OnClosed`, `:161-170`): add `tracker?.RecordBreakerClose(...)` beside the existing `RecordSuccess`. Refresh the ctor `logger` xmldoc (`:38-42`, "until the Admin /hosts reader ships" becomes stale once this lands).
+3. **Invoker success-reset** — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`: on each `Execute*` success path (after `pipeline.ExecuteAsync` returns, before the `finally`), call `_statusTracker?.RecordSuccess(_driverInstanceId, hostName, utcNow)`. (Clock: reuse the invoker's existing time source if present, else `DateTime.UtcNow` matching current tracker call sites.)
+4. **Wire message** — new `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverResilienceStatusChanged.cs` (record above). Plain Commons record = same DPS serialization path `DriverHealthChanged` already rides.
+5. **Publisher** — new `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverResilienceStatusPublisherService.cs` with `public static IReadOnlyList BuildMessages(DriverResilienceStatusTracker tracker, DateTime publishedUtc)` (pure, unit-testable) + the `BackgroundService` timer/publish/try-catch shell.
+6. **Publisher DI** — `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (`AddOtOpcUaDriverFactories`): `services.TryAddSingleton>(sp => () => sp.GetRequiredService());` + `services.AddHostedService();` with a comment naming this the tracker's production reader.
+7. **Admin store** — new `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs` + `InMemoryDriverResilienceStatusStore.cs`; register in `HubServiceCollectionExtensions.AddOtOpcUaDriverStatusServices`.
+8. **Bridge actor** — new `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs`; spawn + `ActorRegistry` key in `HubServiceCollectionExtensions.WithOtOpcUaSignalRBridges` (name const `driver-resilience-status-bridge`).
+9. **Panel** — `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`: inject the store, resilience section per (d), subscribe/unsubscribe per the existing pattern.
+10. **Docs/ledger** — update `archreview/03-server-runtime.md` (U10), `archreview/04-adminui.md` (U-5), `archreview/plans/STATUS.md`, and FOLLOWUP-10's "no reader" notes once merged; `docs/` has no resilience guide to touch (the tracker/builder xmldocs updated in steps 1-2 are the canonical doc surface).
+
+## Tests
+
+- **Unit — read model (Core.Tests, existing suites):**
+  - `DriverResilienceStatusTrackerTests`: `RecordBreakerClose` stamps the close time; `IsBreakerOpen` is false initially, true after open, false after later close, true again after re-open.
+  - `DriverResiliencePipelineBuilderTests`: driving a pipeline to breaker-open then recovery records open **and close** on the tracker (extend the existing breaker test).
+  - `CapabilityInvokerTests` (or `InFlightCounterTests` home): a successful `ExecuteAsync` resets `ConsecutiveFailures` previously raised by a failure.
+- **Unit — publisher mapping (Host.IntegrationTests, pure-DI/no fixture):** `BuildMessages` maps a fed tracker to one message per (instance, host) with correct `BreakerOpen`/counter/`PublishedUtc`; empty tracker → empty list.
+- **Wiring guard — the tracker now HAS a production reader** (the built-but-never-wired class of bug, mirroring `ResilienceInvokerFactoryRegistrationTests`): assert `AddOtOpcUaDriverFactories()` registers an `IHostedService` descriptor whose implementation type is `DriverResilienceStatusPublisherService` — a regression that unregisters the reader fails this test, not production.
+- **Unit — store (AdminUI.Tests, mirroring `DriverStatusSnapshotStoreTests`):** upsert keyed by (instance, host) with last-write-wins; `GetForInstance` returns all hosts for one instance only; `SnapshotChanged` raised per upsert.
+- **Bridge actor:** logic is one line (subscribe → upsert), same as the untested sibling bridges; covered by the live gate.
+- **Razor (no bUnit — live-`/run` is the verification):** docker-dev rig, **rebuild BOTH central-1 and central-2** (`:9200` round-robins them):
+  1. `docker compose build central-1 central-2 && docker compose up -d` in `docker-dev/`.
+  2. On `http://localhost:9200`, open (or author) an **S7 driver** and point its endpoint at a dead port (e.g. `10.100.0.35:9999`) — per FOLLOWUP-10, S7's connect path throws on a dead socket, unlike Modbus's cheap paths; alternatively `lmxopcua-fix down s7` a live fixture. In `DriverResilienceSection`, author overrides `retryCount: 2`, `breakerFailureThreshold: 2` (small numbers so the breaker opens fast). Deploy.
+  3. On the driver page, watch the panel: consecutive-failures chip climbs, then `Breaker OPEN` appears; correlate with `docker logs` central grep `circuit-breaker OPENED` (the #10 log line — first-ever live observation of it, closing FOLLOWUP-10's deferred behavioral gate as a bonus).
+  4. Refresh until the circuit lands on the **other** central (round-robin) — chips must show there too (proves the per-node DPS bridge/store, not a lucky co-located read).
+  5. Restore the endpoint (`lmxopcua-fix up s7 s7_1500`); on the next successful call the breaker closes → chips clear.
+  6. Stop the driver node (fused rig: stop central-2 while circuit is on central-1): rows dim to `stale` within ~15 s.
+
+## Effort + Risk
+
+**Effort: Medium-small** — ~10 bite-sized tasks; one focused day including the live gate. **Risk: Low.** Everything is additive: three small Core additions (new snapshot field, one hook call, one success-reset call — the only behavior deltas, all confined to telemetry counters), a new message type, a new hosted service, a new store + bridge, and one razor section. No dispatch-path, artifact, or config-shape changes. Blast radius of a publisher bug = missing/wrong chips, never driver behavior. The main live-verify hazard is the known one: razor binding errors that unit tests can't see — hence the mandatory live-`/run` pass on both centrals.
+
+---
+
+## Task breakdown
+
+> Branch: `fix/archreview-r2-10-resilience-observability` off `master`. TDD throughout: write the failing test, see FAIL, implement, see PASS. Commit per task (or per labeled group).
+
+### T1 — Tracker: breaker-close marker + `IsBreakerOpen`
+**Classification:** Core read model (unit-testable) · **Estimated implement time:** 5 min · **Parallelizable with:** T4, T7 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs`, `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceStatusTrackerTests.cs`
+1. Add tests: `RecordBreakerClose_StampsCloseTime`, `IsBreakerOpen_TracksOpenCloseReopen` → `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResilienceStatusTrackerTests"` → **FAIL** (no such members).
+2. Add `LastBreakerClosedUtc` init-prop + computed `IsBreakerOpen` to `ResilienceStatusSnapshot`; add `RecordBreakerClose`. Fix the stale class xmldoc (`:5-16`).
+3. Re-run filter → **PASS**. Commit: `feat(resilience): breaker-close marker + IsBreakerOpen on ResilienceStatusSnapshot (R2-10)`.
+
+### T2 — Builder: record breaker-close in `OnClosed`
+**Classification:** Core wiring (unit-testable) · **Estimated implement time:** 5 min · **Parallelizable with:** — (needs T1) · **blockedBy:** T1 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs`, `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs`
+1. Extend the existing breaker-telemetry test: after recovery, tracker snapshot has `IsBreakerOpen == false` with a close stamp → `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResiliencePipelineBuilderTests"` → **FAIL**.
+2. Add `tracker?.RecordBreakerClose(...)` in `OnClosed` (`:161-170`); refresh the ctor logger xmldoc (`:38-42`).
+3. → **PASS**. Commit: `feat(resilience): OnClosed records breaker-close on the status tracker (R2-10)`.
+
+### T3 — Invoker: reset consecutive failures on success
+**Classification:** Core wiring (unit-testable) · **Estimated implement time:** 5 min · **Parallelizable with:** T2 (after T1) · **blockedBy:** T1 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`, `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/CapabilityInvokerTests.cs`
+1. Test: `ExecuteAsync_OnSuccess_ResetsConsecutiveFailures` (record a failure, run a succeeding call, assert 0) → `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~CapabilityInvokerTests"` → **FAIL**.
+2. Call `_statusTracker?.RecordSuccess(...)` on each `Execute*` success path.
+3. → **PASS**. Commit: `fix(resilience): successful capability calls reset ConsecutiveFailures (R2-10)`.
+
+### T4 — Commons: `DriverResilienceStatusChanged` wire message
+**Classification:** Contract (compile-verified; consumed by T5/T8 tests) · **Estimated implement time:** 3 min · **Parallelizable with:** T1, T7 · **Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverResilienceStatusChanged.cs`
+1. Add the record + `TopicName = "driver-resilience-status"` const, xmldoc naming publisher + bridge (mirror `DriverHealthChanged`'s single-source-of-truth note). Commit: `feat(resilience): DriverResilienceStatusChanged DPS contract (R2-10)`.
+
+### T5 — Host: publisher service (`BuildMessages` + timer shell)
+**Classification:** Transport (mapping unit-testable; shell live-verified) · **Estimated implement time:** 5 min · **Parallelizable with:** T7 · **blockedBy:** T1, T4 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverResilienceStatusPublisherService.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverResilienceStatusPublisherServiceTests.cs`
+1. Tests (pure, no fixture): `BuildMessages_MapsTrackerSnapshot` (fed tracker → one msg per (instance,host), correct BreakerOpen/counters/PublishedUtc), `BuildMessages_EmptyTracker_ReturnsEmpty` → `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~DriverResilienceStatusPublisherServiceTests"` → **FAIL**.
+2. Implement `public static BuildMessages(tracker, publishedUtc)` + `BackgroundService` (`PeriodicTimer` 5 s default via ctor param, per-tick try/catch, skip-empty, DPS `Publish` per message via `Func`).
+3. → **PASS**. Commit: `feat(resilience): periodic DPS publisher for tracker snapshots (R2-10)`.
+
+### T6 — Host: DI registration + reader wiring guard
+**Classification:** Wiring + guard (the built-but-never-wired class) · **Estimated implement time:** 5 min · **blockedBy:** T5 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceStatusReaderRegistrationTests.cs`
+1. Guard test (mirror `ResilienceInvokerFactoryRegistrationTests`): `AddOtOpcUaDriverFactories` yields an `IHostedService` descriptor with implementation type `DriverResilienceStatusPublisherService` — **the assertion that the tracker now has a production reader** → `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~ResilienceStatusReaderRegistrationTests"` → **FAIL**.
+2. Register `TryAddSingleton>` + `AddHostedService()` in `AddOtOpcUaDriverFactories`, with a comment naming it the tracker's reader.
+3. → **PASS**. Commit: `feat(resilience): register the tracker's production reader on driver nodes + wiring guard (R2-10)`.
+
+### T7 — AdminUI: resilience status store
+**Classification:** Admin read model (unit-testable) · **Estimated implement time:** 5 min · **Parallelizable with:** T1-T6 (needs only T4) · **blockedBy:** T4 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs`, `.../InMemoryDriverResilienceStatusStore.cs`, `.../HubServiceCollectionExtensions.cs`, `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs`
+1. Tests (mirror `DriverStatusSnapshotStoreTests`): upsert last-write-wins per (instance,host); `GetForInstance` filters; `SnapshotChanged` fires per upsert → `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~DriverResilienceStatusStoreTests"` → **FAIL**.
+2. Implement + register in `AddOtOpcUaDriverStatusServices`.
+3. → **PASS**. Commit: `feat(adminui): in-process resilience status store (R2-10)`.
+
+### T8 — AdminUI: DPS bridge actor + spawn
+**Classification:** Admin wiring (one-line logic; live-verified) · **Estimated implement time:** 5 min · **blockedBy:** T7 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs`, `.../HubServiceCollectionExtensions.cs`
+1. Implement `ReceiveActor` (PreStart DPS `Subscribe(DriverResilienceStatusChanged.TopicName)`, `Receive` → `store.Upsert`, swallow `SubscribeAck`); spawn in `WithOtOpcUaSignalRBridges` + `DriverResilienceStatusBridgeKey`. Build-verify (`dotnet build` AdminUI project); behavior covered by the live gate. Commit: `feat(adminui): driver-resilience-status DPS bridge (R2-10)`.
+
+### T9 — AdminUI: `DriverStatusPanel` resilience section
+**Classification:** Razor (NO bUnit — live-`/run` is the only verification for this task) · **Estimated implement time:** 5 min (code) + live pass in T10 · **blockedBy:** T7 · **Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`
+1. Inject `IDriverResilienceStatusStore`; add the per-host chip rows (Breaker OPEN / consecutive failures / in-flight / stale-after-15 s / "No resilience activity recorded yet"); subscribe + filter by `DriverInstanceId`, `InvokeAsync(StateHasChanged)`, unsubscribe in `DisposeAsync` — copy the existing `SnapshotChanged` pattern in the same file verbatim. Remember the string-component-param-needs-`@` gotcha. Build-verify. Commit: `feat(adminui): breaker/retry chips on the driver status panel (R2-10)`.
+
+### T10 — Live-`/run` gate (decisive for T8/T9)
+**Classification:** Live verification (mandatory — razor + DPS delivery are invisible to unit tests) · **Estimated implement time:** 30-45 min · **blockedBy:** T6, T8, T9
+1. `docker-dev`: rebuild **BOTH** central-1 + central-2 from source, `up -d`.
+2. Author/point an S7 driver at a dead endpoint (or `lmxopcua-fix down s7`); set overrides `retryCount: 2`, `breakerFailureThreshold: 2`; deploy.
+3. Observe: failures chip climbs → `Breaker OPEN` chip; grep central logs for `circuit-breaker OPENED` to correlate (also the first live observation of the #10 log line — note it against FOLLOWUP-10's deferred behavioral gate).
+4. Refresh onto the other central (round-robin) — chips present there too.
+5. Restore endpoint → breaker closes → chips clear. Stop one central → rows go `stale` in ~15 s.
+6. Commit any live-found fixes; record the evidence in the PR/plan status line.
+
+### T11 — Docs + review ledger
+**Classification:** Docs · **Estimated implement time:** 5 min · **blockedBy:** T10 · **Files:** `archreview/03-server-runtime.md`, `archreview/04-adminui.md`, `archreview/plans/STATUS.md`, `archreview/plans/FOLLOWUP-10-resilience-dispatch-gap.md`
+1. Mark U10/U-5 remediated with the branch/commit; update STATUS.md; annotate FOLLOWUP-10 that the reader shipped and (if observed in T10) the live behavioral log-line gate closed. Commit: `docs(archreview): R2-10 resilience observability shipped`.
+
+**Full-suite gate before merge:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green (integration suites need their fixtures per CLAUDE.md; the new tests themselves are fixture-free).
diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json
new file mode 100644
index 00000000..7498bc68
--- /dev/null
+++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json
@@ -0,0 +1,17 @@
+{
+  "planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    { "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "pending", "blockedBy": [] },
+    { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "pending", "blockedBy": ["T1"] },
+    { "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "pending", "blockedBy": [] },
+    { "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "pending", "blockedBy": ["T1", "T4"] },
+    { "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "pending", "blockedBy": ["T5"] },
+    { "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "pending", "blockedBy": ["T4"] },
+    { "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "pending", "blockedBy": ["T7"] },
+    { "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "pending", "blockedBy": ["T7"] },
+    { "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "pending", "blockedBy": ["T6", "T8", "T9"] },
+    { "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "pending", "blockedBy": ["T10"] }
+  ]
+}
diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md b/archreview/plans/R2-11-tagconfig-consolidation-plan.md
new file mode 100644
index 00000000..1e14a347
--- /dev/null
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md
@@ -0,0 +1,496 @@
+# Design + Implementation Plan — R2-11: TagConfig parse consolidation + driver parse-strictness unification
+
+- **Source reports:** `archreview/01-core-composition.md` (C-1, P-1) + `archreview/05-protocol-drivers.md` (CONV-2, UNDER-1 parser seam, UNDER-6) — 2026-07-12 re-review (working-tree versions)
+- **Action item:** #11 in `archreview/00-OVERALL.md` ("TagConfig parse consolidation (`TagConfigIntent.Parse` in Commons) + `ReadEnum` unification — 01 C-1/P-1, 05 — Medium/Medium")
+- **Round-1 plans unified:** `archreview/plans/01-core-composition-plan.md` §2 (C-1) + §"P-1", and `archreview/plans/05-protocol-drivers-plan.md` Part B §B2
+- **Plan verified against tree at:** `f6eaa267` (master, clean working tree except archreview/ doc updates)
+- **Scope:** `Commons`, `Core.Abstractions`, `Configuration`, `Core` (walker), `OpcUaServer` (composer), `Runtime` (artifact), the six protocol-driver `.Contracts` projects + driver hosts, `ControlPlane` (deploy gate), `AdminUI` (typed editors — sync only)
+
+## Verification summary
+
+Every cited finding was re-opened at the working-tree source. **All CONFIRMED — none stale.** The re-review's C-1/P-1 status ("STILL OPEN — all four copies + comments intact") matches the tree.
+
+### The four byte-parity TagConfig parse sites (01/C-1)
+
+| # | Project | File / members | Notes |
+|---|---|---|---|
+| 1 | `ZB.MOM.WW.OtOpcUa.OpcUaServer` | `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — `ExtractTagFullName` (:536-551), `TryExtractDeviceHost` (~:567) + `NormalizeDeviceHost`, `ExtractTagAlarm` / `ExtractTagHistorize` / `ExtractTagArray` (:601-686; doc-comment "MUST parse identically" at :598/:630/:662) | The **canonical copy**. Called 4× per tag from the equipment-tag `Select` (:417-433) — this is P-1's 4 `JsonDocument.Parse` per tag (:541/:606/:638/:670). `EquipmentTagAlarmInfo` record defined here (:146). |
+| 2 | `ZB.MOM.WW.OtOpcUa.Runtime` | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — private mirrors `ExtractTagFullName` (~:668-689), `ExtractTagAlarm` (comment :693), `ExtractTagHistorize` (:723), `ExtractTagArray` (:751) | Artifact-decode mirror; each carries the "MUST parse identically (byte-parity)" comment. Also calls `AddressSpaceComposer.TryExtractDeviceHost` cross-project at :837 (device-host is already single-sourced, just housed in the wrong project). |
+| 3 | `ZB.MOM.WW.OtOpcUa.Core` | `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs:193-208` — `internal static ExtractFullName` | Dormant path (01/U-1); FullName-only copy. Multiple tree-wide doc comments cite it as "the canonical parser". |
+| 4 | `ZB.MOM.WW.OtOpcUa.Configuration` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs:57-71` — private `ExtractTagConfigFullName` | FullName-only copy with **different semantics**: returns `null` on absent (explicit-FullName check for the Galaxy rule at :41-56), not the raw-blob fallback. Any consolidation must preserve this distinction. |
+
+Re-review note: #13 added a fifth *parity-coupled* read (`DeploymentArtifact.TryReadSpec` for `ResilienceConfig`, :168-181) — a trivial null-safe string passthrough with no intent parsing. **Out of scope here** (no consolidation value; noted so nobody "helpfully" folds it in).
+
+### The `ReadEnum` census (05/CONV-2)
+
+Six byte-identical private copies of `ReadEnum(JsonElement, string, TEnum fallback)` — silent fallback on present-but-invalid values (a typo'd `dataType` reads/writes the wrong width with `Good` status):
+
+| Driver | File:line (helper) | Enum fields read | `Writable` handling |
+|---|---|---|---|
+| Modbus | `Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:65-67` | `region`, `dataType`, `byteOrder` (:32-34) | hard-coded `Writable: true` (:57) |
+| S7 | `Driver.S7.Contracts/S7EquipmentTagParser.cs:59-61` | `dataType` (:34) | hard-coded `true` (:49, "node-level authz governs writes") |
+| AbCip | `Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:86-88` | `dataType` (:45) | **honours optional `"writable"` key, default true (:52-57) — the model** |
+| AbLegacy | `Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:60-62` | `dataType` (:41) | hard-coded `true` (:52) |
+| TwinCAT | `Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs:47-49` | `dataType` (:31) | hard-coded `true` (:39) |
+| FOCAS | `Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs:43-45` | `dataType` (:31) | hard-coded `true` (:35) — **yet the only real backend returns `BadNotWritable` for every address** (05/UNDER-1, `Wire/WireFocasClient.cs:71-73`) |
+
+Adjacent confirmed defects folded into scope (05 report):
+- **FOCAS capability-gate bypass:** authored tags run `FocasCapabilityMatrix.Validate` at init (`FocasDriver.cs:115-119`); equipment tags resolved via `_parseRef` (`FocasDriver.cs:69`) reach `ResolveParsedAddress` (:243-247) with **no matrix check** — only the address-parse throw.
+- **Modbus probe parity break:** `ModbusDriverProbe` binds the *runtime* `ModbusDriverOptions` (TimeSpan fields), not the factory DTO shape (`ModbusDriverProbe.cs:19-24, 36`) — a DB config with `timeoutMs` silently loses its timeout in the probe. OpcUaClient documents probe/factory parse-parity as the rule (`OpcUaClientDriverProbe.cs:22`).
+
+### Structural facts that shape the design (all verified in-tree)
+
+- **`Commons` is a near-leaf** (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/`): zero `ProjectReference`s, packages only `Akka` + `ZB.MOM.WW.Audit`. Houses the precedent `Types/EquipmentScriptPaths.cs` — created exactly to de-duplicate a composer/artifact cross-seam parser. Referenced today by `OpcUaServer`, `Runtime`, `ControlPlane` — **but NOT by `Configuration`** (which references only `Core.Abstractions`). Adding `Configuration → Commons` is cycle-safe (Commons references nothing in-repo).
+- **`Core.Abstractions` is a pure leaf** (zero `ProjectReference`s, zero `PackageReference`s). Houses `EquipmentTagRefResolver` — the shared seam all six drivers construct (`ModbusDriver.cs:106`, `S7Driver.cs:53`, `TwinCATDriver.cs:66`, `AbLegacyDriver.cs:62`, `AbCipDriver.cs:130`, `FocasDriver.cs:69`).
+- **The driver `.Contracts` projects are deliberately dependency-free**: `S7.Contracts` and `FOCAS.Contracts` carry an explicit `` banner; AbCip/AbLegacy/TwinCAT Contracts have no references; `Modbus.Contracts` references only `Modbus.Addressing`. **AdminUI references Contracts projects directly** (the typed tag editors reuse driver enums) — this is why they must stay dependency-light. A `Core.Abstractions` reference adds a zero-dependency leaf assembly, so the banner's *intent* (no dependency-closure growth for lightweight consumers) is preserved.
+- **Deploy gate:** `AdminOperationsActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs:176`) runs `DraftValidator.Validate(draft)` and rejects on **any** `ValidationError` (record `Code`/`Message`/`Context` — **no severity field, no warnings channel exists today**). Result message: `StartDeploymentResult` (`Commons/Messages/Admin/StartDeploymentResult.cs`) carries a free-text `Message`. `ControlPlane` does **not** reference any driver Contracts project (csproj :22-28).
+- **AdminUI-side validation:** `AdminUI/Uns/TagEditors/TagConfigValidator.cs` dispatches by `DriverType` (canonical strings `"Modbus"`, `"S7"`, `"AbCip"`, `"AbLegacy"`, `"TwinCat"`, `"Focas"`, `"OpcUaClient"`, case-insensitive) to typed `TagConfigModel.Validate()`; models are string-enum (`JsonStringEnumConverter` — the driver-enum-serialization history) and **preserve unknown keys**.
+- **Existing test assets to build on:** `OpcUaServer.Tests/ExtractTagAlarmTests.cs`, `ExtractTagHistorizeTests.cs`, `ExtractTagArrayTests.cs` (direct tests of the composer internals — `InternalsVisibleTo` at OpcUaServer csproj :25); `Runtime.Tests/Drivers/DeploymentArtifact{Historize,Array,Alias,DeviceHost}ParityTests.cs` (compose→encode→decode parity pattern); `Commons.Tests/EquipmentScriptPathsTests.cs`; `Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs`.
+
+---
+
+# Part 1 — 01/C-1 + 01/P-1: `TagConfigIntent.Parse` in Commons
+
+## Restatement
+
+The platform's cross-driver TagConfig *intent* keys (`FullName`, `alarm`, `isHistorized`/`historianTagname`, `isArray`/`arrayLength`, plus the DeviceConfig `HostAddress`) are parsed by four hand-synced copies across four projects, held equal only by "MUST parse identically (byte-parity)" comments. The equality of the live-compose and artifact-decode address spaces — and therefore diff/no-op-redeploy correctness — rests on humans keeping four JSON parsers in sync (C-1). Separately, the composer parses the *same* blob four times per tag: 40k parses for a 10k-tag fleet per compose, doubled on the artifact-decode side (P-1).
+
+## Verification
+
+Confirmed as enumerated above. The four call sites in the composer's equipment-tag `Select` (:417-433) each trigger an independent `JsonDocument.Parse` (:541, :606, :638, :670). Site semantics are *almost* identical with two deliberate variations the shared type must carry:
+
+1. **FullName fallback:** composer/artifact/walker fall back to the **raw blob** when `FullName` is absent/non-string/non-object/malformed (an equipment tag's TagConfig JSON *is* its wire reference for the six protocol drivers — the resolver's `_parseRef` path depends on this). `DraftValidator` returns **`null`** on absent — it wants the *explicit* FullName only (Galaxy rule).
+2. **Null input:** artifact tolerates `null` (`tagConfig ?? string.Empty`); composer/walker take non-null (DB `CK_Tag_TagConfig_IsJson` guarantees it). `Parse(string?)` returning `FullName = tagConfig ?? string.Empty` preserves both.
+
+Semantics to port verbatim (the byte-parity contract):
+- `alarm`: object-typed `alarm` property only; `alarmType` string default `"AlarmCondition"`; `severity` int32 number default `500`; `historizeToAveva` **bool?** — `null` unless explicitly `true`/`false` (absent/non-bool ⇒ null ⇒ default-on at the HistorianAdapterActor gate).
+- `isHistorized`: `true` only when a bool `true`; `historianTagname` non-whitespace string else `null`, **not trimmed**.
+- `isArray`: bool; `arrayLength` honoured **only when `isArray` is true** AND a JSON number fitting `uint` (else `null` ⇒ unbounded `ArrayDimensions=[0]`).
+- Malformed JSON / non-object root / blank ⇒ all intents default (FullName = raw blob), **never throws**.
+
+## Root cause
+
+No shared home existed on the dependency graph: OpcUaServer doesn't reference the Core driver assembly, Configuration doesn't reference Commons — so each seam grew its own copy, synced by comments (OVERALL cross-cutting theme #2, "fixes don't flow back to shared templates"). The codebase already demonstrated the fix once: `EquipmentScriptPaths` was created in Commons to de-duplicate exactly this composer/artifact seam for script dependency-ref extraction.
+
+## Proposed design
+
+One record + one parse in **`Commons/Types/`** (the `EquipmentScriptPaths` precedent), consumed by all four seams. Parsing once per tag resolves P-1 in the same change.
+
+```csharp
+// src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+/// The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON.
+/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
+/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft gate
+/// (DraftValidator), and the walker. One JsonDocument.Parse per blob. Never throws.
+public sealed record TagConfigIntent(
+    string FullName,            // top-level "FullName" string, else the raw blob (wire-reference fallback)
+    string? ExplicitFullName,   // the "FullName" property when present-and-string, else null (DraftValidator semantics)
+    TagAlarmIntent? Alarm,
+    bool IsHistorized,
+    string? HistorianTagname,
+    bool IsArray,
+    uint? ArrayLength)
+{
+    public static TagConfigIntent Parse(string? tagConfig);
+}
+
+/// The optional native-alarm intent ("alarm" object).
+public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
+
+// src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
+public static class DeviceConfigIntent
+{
+    public static string? TryExtractHost(string? deviceConfigJson);  // parse + normalize; null on absent/blank/unparseable
+    public static string NormalizeHost(string host);                 // trim + ToLowerInvariant — the single normalization authority
+}
+```
+
+Seam retrofit:
+- **Composer** (`AddressSpaceComposer.cs`): the equipment-tag `Select` computes `var intent = TagConfigIntent.Parse(t.TagConfig);` **once** and projects all fields off it; `Alarm` maps `TagAlarmIntent → EquipmentTagAlarmInfo` (one-line ctor call — `EquipmentTagAlarmInfo` stays in OpcUaServer because the plan records and their consumers live there). The four private/internal `Extract*` statics are deleted. `TryExtractDeviceHost`/`NormalizeDeviceHost` delegate to (then are replaced by) `DeviceConfigIntent`.
+- **DeploymentArtifact** (`Runtime`): the four private mirrors are deleted; the tag-decode loop calls `TagConfigIntent.Parse` once per tag element; the `:837` device-host call moves to `DeviceConfigIntent.TryExtractHost`. `DriverHostActor` (:742, :776) moves to `DeviceConfigIntent.NormalizeHost`.
+- **DraftValidator** (`Configuration`): gains a `ProjectReference` to Commons (cycle-safe — verified: Commons has zero in-repo references); `ExtractTagConfigFullName(...)` → `TagConfigIntent.Parse(t.TagConfig).ExplicitFullName` (null-on-absent preserved by construction — this is why the record carries both `FullName` and `ExplicitFullName`).
+- **EquipmentNodeWalker** (`Core`): `ExtractFullName` body becomes a one-line delegate to `TagConfigIntent.Parse(...).FullName`. (Full walker deletion is round-1 U-1's job, not this plan's — but the swap removes the fourth parser copy regardless of when U-1 lands, and the tree-wide doc comments citing the walker as "the canonical parser" — `DeploymentArtifact.cs:666`, `OpcUaClientTagConfigModel.cs:11`, `IScriptTagCatalog.cs`, `OtOpcUaNodeManager.cs` — are repointed to `TagConfigIntent`.)
+
+**Alternatives considered and rejected:**
+- *House the record in `Core.Abstractions`* — rejected: `Configuration` already references it, but `Commons` is the established home for exactly this kind of cross-seam pure parser (`EquipmentScriptPaths`), and OpcUaServer/Runtime/ControlPlane already reference Commons. Splitting "script-path intents in Commons, tag-config intents in Core.Abstractions" would scatter the shared-contract home the reports say is missing.
+- *Move `EquipmentTagAlarmInfo` to Commons instead of mapping* — rejected: it participates in OpcUaServer's public plan-record surface and its (de)serialization; the two one-line mappings are covered by the parity corpus and avoid public-API churn.
+- *Keep four parsers + add a golden-file parity test* — rejected (round-1 concurs): tests the symptom; still four code paths to drift.
+- *Fold `TryReadSpec`'s ResilienceConfig read in* — rejected: trivial string passthrough, no intent semantics; consolidation would couple the artifact spec format to Commons for zero dedup value.
+
+## Implementation steps (byte-parity test FIRST, then swap one site at a time)
+
+1. **Characterization net first:** add a golden TagConfig corpus (~30 blobs: happy path, blank/null, non-object root, malformed JSON, `FullName` absent/non-string, alarm object with each field absent/wrong-type, `historizeToAveva` true/false/non-bool, `historianTagname` whitespace, `isArray`/`arrayLength` combinations incl. negative/overflow/non-number/length-without-flag, unknown keys, platform+driver keys mixed) and a compose→encode→decode parity test in `Runtime.Tests` (the existing `DeploymentArtifact*ParityTests` pattern) asserting the composer plan equals the decoded plan field-by-field over the whole corpus. **This must pass on the unmodified tree** — it proves sites 1+2 parse identically TODAY and stays green through every subsequent swap. Add corpus-driven FullName characterization for the walker (`Core.Tests`) and the DraftValidator Galaxy rule (`Configuration.Tests`).
+2. Add `Commons/Types/TagConfigIntent.cs` + `DeviceConfigIntent.cs`, porting semantics **verbatim** from the composer (the canonical copy). Port the three `OpcUaServer.Tests/ExtractTag*Tests` tables into `Commons.Tests/TagConfigIntentTests` — this becomes the single byte-parity authority.
+3. Swap the composer (parse-once), keeping the parity corpus green. 4. Swap DeploymentArtifact. 5. Swap DraftValidator (+ `Configuration → Commons` reference). 6. Swap the walker + repoint doc comments. 7. Re-home device-host (composer statics deleted; `DriverHostActor`/`DeploymentArtifact` callers updated). 8. Retire the now-redundant `OpcUaServer.Tests/ExtractTag*Tests` (tables live in Commons.Tests; the compose-level behavior is held by the parity corpus). 9. Final sweep: `grep -rn "MUST parse identically" src/` returns zero hits.
+
+## Tests
+
+- `Commons.Tests/TagConfigIntentTests` — exhaustive table (ported + extended): every field × present/absent/wrong-type/malformed; `ExplicitFullName` vs `FullName` fallback distinction; never-throws property over the corpus.
+- `Commons.Tests/DeviceConfigIntentTests` — ported from composer semantics + `DeploymentArtifactDeviceHostParityTests` expectations (normalization idempotence, null-on-blank).
+- `Runtime.Tests/Drivers/TagConfigCorpusParityTests` — the cross-seam characterization (step 1); permanent regression guard.
+- Existing `DeploymentArtifact{Historize,Array,Alias,DeviceHost}ParityTests` must stay green untouched throughout.
+
+## Effort + Risk
+
+**Effort: M** (mostly mechanical after the corpus lands). **Risk: Medium** — touches the byte-parity crown-jewel invariant. Mitigations: characterization corpus lands *before* any code moves; semantics ported verbatim (no behavior change intended anywhere in Part 1); one seam swapped per commit with the corpus green after each; `TreatWarningsAsErrors` + the compiler prove no stale callers.
+
+---
+
+# Part 2 — 05/CONV-2 (+UNDER-1/UNDER-6 seams): shared `ReadEnum`, Writable defaults, two-mode strictness rollout
+
+## Restatement
+
+The equipment-tag TagConfig path — **the product's primary authoring surface since Galaxy-standard-driver** — is the *most permissive* of the three parse tiers: six copy-pasted `ReadEnum` helpers silently default a typo'd enum (wrong-width reads/writes with `Good` status); four parsers hard-code `Writable: true` (FOCAS advertises writes its only backend always rejects); FOCAS equipment tags bypass the capability matrix authored tags get; the Modbus probe parses a different shape than the factory.
+
+## Verification
+
+Confirmed as tabulated in the Verification summary. Key structural check performed beyond the reports: **the round-1 B2 design ("hoist into Core.Abstractions beside `EquipmentTagRefResolver`") collides with the Contracts projects' explicit `NO ProjectReference` banner** (`S7.Contracts`/`FOCAS.Contracts` csproj). Resolution below.
+
+## Root cause
+
+Duplication-by-convention (theme #2) — the strict fail-loud `ParseEnum` exists in every factory, but the fix never flowed to the equipment-tag tier; and the equipment-tag parsers were written when authored tags were primary, so nobody revisited their defaults when TagConfig became the product.
+
+## Proposed design
+
+### 1. Shared helper home: `Core.Abstractions/TagConfigJson.cs` (banner amended)
+
+```csharp
+// src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+public enum JsonEnumRead { Absent, Valid, Invalid }
+
+/// Shared TagConfig JSON field readers for the six equipment-tag parsers.
+/// Replaces the six byte-identical private ReadEnum copies (05/CONV-2).
+public static class TagConfigJson
+{
+    /// Distinguishes absent / valid / present-but-invalid so callers can be
+    /// lenient (fall back) while still *reporting* the invalid case.
+    public static JsonEnumRead TryReadEnum(JsonElement o, string name, out TEnum value)
+        where TEnum : struct, Enum;
+
+    /// Lenient shim with today's exact semantics (absent OR invalid ⇒ fallback).
+    public static TEnum ReadEnumOrDefault(JsonElement o, string name, TEnum fallback)
+        where TEnum : struct, Enum;
+
+    /// AbCip semantics: explicit JSON false ⇒ false; anything else ⇒ defaultValue.
+    public static bool ReadWritable(JsonElement o, bool defaultValue = true);
+
+    /// Human-readable warning for a present-but-invalid enum ("value 'X' for 'dataType'
+    /// is not a valid ModbusDataType; valid: …"), or null when absent/valid.
+    public static string? DescribeInvalidEnum(JsonElement o, string name)
+        where TEnum : struct, Enum;
+}
+```
+
+Each Contracts project gains `` to `Core.Abstractions` and its banner comment is amended to: ``. **Why this is safe:** `Core.Abstractions` has zero `PackageReference`s and zero `ProjectReference`s (verified), so AdminUI/Cli consumers of the Contracts projects gain exactly one dependency-free assembly — the banner's intent (no closure growth) is preserved, and the helper lands beside `EquipmentTagRefResolver`, the seam theme #6 designated as the drivers' shared home.
+
+**Alternatives considered and rejected:**
+- *Linked shared-source file (`` across six csprojs)* — rejected: zero precedent in this repo (verified: no `Compile Include` links anywhere in `src/`); invisible coupling that IDEs and reviewers routinely miss.
+- *New leaf project `Driver.Contracts.Shared`* — rejected: project sprawl for ~60 lines when a suitable zero-dep leaf already exists and is already the resolver's home.
+- *Keep six copies, make each strict, add a cross-driver conformance test* — rejected: test-enforced parity is the exact anti-pattern C-1 exists to kill.
+
+### 2. Two-mode strictness rollout — **the compatibility decision (documented)**
+
+**Constraint:** parse-strictness changes are behavior-affecting for EXISTING deployed configs. A typo'd enum that silently defaulted yesterday must not brick a deploy (or a running server) today.
+
+**Decision — three phases, this plan ships the first two:**
+
+| Phase | What | Behavior for existing configs |
+|---|---|---|
+| **A (this plan, default)** | Runtime parsers stay **lenient** (`ReadEnumOrDefault` — bit-for-bit today's semantics). Each parser additionally exposes `Inspect(reference)` returning warnings (invalid enum values via `DescribeInvalidEnum`; structurally-unparseable TagConfig, which today becomes a silent runtime `BadNodeIdUnknown`). The deploy gate surfaces these as **non-blocking warnings**: logged + appended to the `StartDeploymentResult.Message` (existing free-text field — no message-contract change). | **Unchanged.** Running servers untouched; re-deploys succeed with visible warnings. |
+| **B (this plan, opt-in)** | New option `Deployment:TagConfigValidationMode` = `Warn` (default) \| `Error`. In `Error` mode the same inspection results fold into the existing `ValidationError` reject list at the draft gate — a config with a typo'd enum **cannot be re-deployed** until fixed. | Running servers still untouched (the gate only sees re-deploys — "existing runtime behavior unchanged unless config is re-deployed through the gate"). Operators flip to `Error` per environment once their fleet deploys warning-clean. |
+| **C (future, out of scope, recorded)** | Flip the runtime parsers to strict (`TryReadEnum == Invalid` ⇒ `TryParse` returns false ⇒ resolver miss ⇒ `BadNodeIdUnknown` instead of wrong-width `Good`) once the fleet has run `Error`-mode clean. Tracked as a follow-up in STATUS.md when this plan executes. | Behavior change, deliberately deferred: the report's "correct outcome" lands only after the gate has proven no live config depends on the silent default. |
+
+**Where the deploy-time inspection runs:** a new `EquipmentTagConfigInspector` static dispatch map in **`ControlPlane`** (mirroring AdminUI's `TagConfigValidator` shape: `DriverType → Func>`, case-insensitive, canonical strings `"Modbus"/"S7"/"AbCip"/"AbLegacy"/"TwinCat"/"Focas"`). ControlPlane gains references to the six Contracts projects — the same dependency-light consumption AdminUI already does; unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in the AdminUI validator. Wired into `AdminOperationsActor` immediately after the `DraftValidator.Validate` gate (:176), over equipment tags only (`EquipmentId != null`). *Alternative rejected:* teaching `DraftValidator` (Configuration) the driver vocab — would couple Configuration to every driver Contracts project and grow per driver; the DriverType-dispatch precedent already lives one layer up.
+
+**Why not surface strictness through the composer:** the composer is pure and deploy-critical ("same inputs → same outputs", 01/C-4) — a warnings channel there would bolt reporting onto the byte-parity seam. The draft gate is the designed authority for config validity (its own doc comment says so) and already has the reject/summary plumbing.
+
+### 3. Unknown-key handling — **decision: always preserve, never warn**
+
+The TagConfig blob is a **shared namespace**: platform intent keys (`FullName`, `alarm`, `isHistorized`, `historianTagname`, `isArray`, `arrayLength`) coexist with per-driver address keys (`region`, `address`, `dataType`, …) and any forward-compat keys the typed editors deliberately preserve (`FromJson`/`ToJson` preserve-unknown-keys contract). There is no single canonical schema to validate against, so unknown-key strictness would false-positive on every platform key from a driver parser's perspective and vice versa. Parsers continue to read known fields and ignore the rest; `Inspect` never warns on unknown keys. Documented as a deliberate non-goal in the helper's XML docs.
+
+### 4. `Writable` default per driver capability
+
+| Driver | Change | Compat |
+|---|---|---|
+| AbCip | none (already honours `writable`, default true) — helper swap only | zero |
+| Modbus, S7, AbLegacy, TwinCAT | honour optional `writable` key via `TagConfigJson.ReadWritable(root)` (default **true**) | zero for existing configs (absent ⇒ true, today's value); makes read-only equipment tags authorable (UNDER-6) |
+| FOCAS | **force `Writable: false`** (ignore any `writable:true`) until PMC writes exist in the wire client; `Inspect` warns when a config requests `writable:true` | behavior-affecting **correction**: a write that today reaches the backend and always fails with `BadNotWritable` (`WireFocasClient.cs:71-73`) now fails at the driver seam with the same family of Bad status — no successful operation changes |
+
+**FOCAS capability gate:** wrap the `_parseRef` lambda in `FocasDriver` (:69) so a parser-produced def immediately runs `FocasAddress.TryParse` + `FocasCapabilityMatrix.Validate(device.Options.Series, parsed)` (device looked up by `def.DeviceHostAddress`); on rejection return `null` ⇒ resolver miss ⇒ `BadNodeIdUnknown` — equipment tags get the same pre-flight authored tags get (:115-119). Behavior-affecting correction: a capability-violating tag that today fails at read-time with a wire error now fails at resolve-time with a precise status.
+
+### 5. Modbus probe parity
+
+`ModbusDriverProbe` parses the factory DTO shape (or a shared `TryParseConfig` used by both), so `timeoutMs` binds identically to the factory (`ModbusDriverProbe.cs:19-24, 36`; the OpcUaClient parity rule, `OpcUaClientDriverProbe.cs:22`). Included because it is the third strictness tier of the same CONV-2 finding; small and independent.
+
+### 6. AdminUI sync
+
+- `TagConfigValidator` / typed editors need **no strictness change**: enum fields are dropdown-bound (string-typed via `JsonStringEnumConverter` — the driver-enum-serialization lesson), so typos cannot be authored through mapped editors; the deploy gate covers SQL-seeded/API-authored configs and the raw-JSON textarea (unmapped drivers).
+- Add a `writable` checkbox to the six typed editor models/editors (default checked; FOCAS's editor shows it disabled-unchecked with a "FOCAS writes unsupported" hint) so the newly-honoured key is authorable without hand-editing JSON. Unknown-key preservation means hand-authored `writable` already round-trips today. **Live-verify caveat:** AdminUI has no bUnit — the checkbox task requires a docker-dev `/run` verification pass when executed (out of this plan's static scope; flagged in the task).
+
+## Implementation steps
+
+1. `TagConfigJson` + tests in `Core.Abstractions` (helper is additive; nothing consumes it yet).
+2. Per driver (six parallel, identical shape): add the Contracts→Core.Abstractions reference (+ banner amendment where present); **behavior-freeze test first** (typo'd enum still silently defaults — pins Phase-A lenient semantics); replace the private `ReadEnum` with `TagConfigJson.ReadEnumOrDefault`; add `Inspect(reference)`; honour/force `writable` per the table; delete the private copy.
+3. FOCAS driver-side capability-gate wrap (+ test: capability-violating equipment ref fails resolve).
+4. `EquipmentTagConfigInspector` in ControlPlane + `AdminOperationsActor` wiring + `TagConfigValidationMode` option. **Consuming test through the actor**, not just the map (the F10b/PR#423 lesson: unit-green ≠ wired).
+5. Modbus probe DTO-shape fix + `timeoutMs` round-trip test.
+6. AdminUI `writable` checkboxes (mechanical; live-verify flagged).
+7. Docs: `docs/Uns.md` TagConfig section (writable key, validation modes), migration note for the FOCAS writable flip, the phase-C follow-up recorded in `archreview/plans/STATUS.md`.
+
+## Tests
+
+- `Core.Abstractions.Tests/TagConfigJsonTests` — `TryReadEnum` Absent/Valid/Invalid matrix (case-insensitive match preserved), `ReadWritable` explicit-false-only semantics, `DescribeInvalidEnum` lists valid names.
+- **Per-driver strictness suite** (each driver's existing `.Tests` project): (a) behavior-freeze — typo'd enum defaults exactly as today (Phase A pin); (b) `Inspect` reports the typo with field name + valid values; (c) `writable:false` honoured (AbCip regression; new for Modbus/S7/AbLegacy/TwinCAT); (d) FOCAS: `Writable` always false + `writable:true` warning; (e) absent enum ⇒ documented default (unchanged).
+- **Draft-gate warning tests** (ControlPlane/Admin tests — note: `*.Admin.Tests` are DB-backed per CLAUDE.md, so prefer a unit seam on the inspector + one actor-level test with an in-memory draft): Warn mode — deploy accepted, warnings in log + result message; Error mode — deploy rejected with the same texts folded into the error summary; clean config — zero warnings, no message noise; unmapped driver (Galaxy) — skipped.
+- Modbus probe: config with `timeoutMs` round-trips the timeout value.
+
+## Effort + Risk
+
+**Effort: M** (six mechanical parser swaps + one gate wiring). **Risk: Low-Medium.** Phase A is behavior-frozen by construction (freeze tests land before each swap); the only behavior-affecting edges are the two documented FOCAS corrections (write path already always failed; gate moves failure earlier) and the opt-in `Error` mode (off by default). The wiring risk (inspector built but never called) is covered by the actor-level consuming test.
+
+---
+
+# Overall effort + sequencing
+
+**Total: M — roughly 2–2.5 focused dev-days across 24 bite-sized tasks.** Part 1 and Part 2 are independent until T24 and can proceed in parallel branches (`refactor/r2-11-tagconfig-intent`, `feat/r2-11-parse-strictness`); Part 1's seam swaps are strictly sequential behind the corpus; Part 2's six driver tasks are mutually parallel behind the helper. Worktree-isolate parallel implementers (the shared-tree git-index race noted in project memory).
+
+---
+
+# Task breakdown
+
+> TDD discipline: tasks marked *characterization* must be **green on the unmodified tree** before their companion refactor starts (they pin today's behavior); all other test steps follow FAIL→PASS. All paths absolute from repo root `/Users/dohertj2/Desktop/OtOpcUa/`.
+
+### T1 — Golden corpus + compose→encode→decode parity characterization test
+
+**Classification:** test-only (characterization — must pass on unmodified tree)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T3, T4, T11
+**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs` (new), `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs` (new)
+**Steps:** (1) Author the ~30-blob corpus (see Part 1 step 1 for the case list) as a shared static. (2) Following the `DeploymentArtifactHistorizeParityTests` pattern, compose a fleet with one tag per corpus blob, encode→decode, assert `EquipmentTagPlan` equality field-by-field (FullName, Alarm, IsHistorized, HistorianTagname, IsArray, ArrayLength). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests"` → **PASS on unmodified tree** (this is the proof the 4 sites parse identically today).
+**Commit:** `test(runtime): golden TagConfig corpus + compose/decode parity characterization (R2-11)`
+
+### T2 — Corpus characterization for walker + DraftValidator FullName semantics
+
+**Classification:** test-only (characterization)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1, T3, T4
+**Files:** `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs` (new)
+**Steps:** (1) Walker: table-test `EquipmentNodeWalker.ExtractFullName` over the corpus expecting the raw-blob-fallback column. (2) DraftValidator: Galaxy-driver drafts per corpus blob asserting `GalaxyTagMissingReference` fires exactly for the null-`ExplicitFullName` rows. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~EquipmentNodeWalkerFullNameCorpusTests"` and `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~DraftValidatorGalaxyFullNameCorpusTests"` → PASS on unmodified tree.
+**Commit:** `test(core,config): FullName-semantics characterization for walker + draft gate (R2-11)`
+
+### T3 — `TagConfigIntent` + `TagAlarmIntent` in Commons (TDD)
+
+**Classification:** structural (no behavior change; new type)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T1, T2, T4, T11
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs` (new)
+**Steps:** (1) Write `TagConfigIntentTests` first — port the three `OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests` tables + FullName/ExplicitFullName cases + never-throws over the corpus → FAIL (type absent). (2) Implement `TagConfigIntent.Parse` porting composer semantics **verbatim** (single `JsonDocument.Parse`; raw-blob FullName fallback; `Parse(null).FullName == ""`; alarm defaults `"AlarmCondition"`/500/null; untrimmed `historianTagname`; `arrayLength` only when `isArray`). (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~TagConfigIntentTests"` → PASS.
+**Commit:** `feat(commons): TagConfigIntent.Parse — single home for the byte-parity TagConfig intents (R2-11, 01/C-1)`
+
+### T4 — `DeviceConfigIntent` in Commons (TDD)
+
+**Classification:** structural (no behavior change; new type)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T1–T3, T11
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs` (new)
+**Steps:** (1) Tests first (port composer `TryExtractDeviceHost`/`NormalizeDeviceHost` semantics: null on blank/non-object/non-string/whitespace; trim+lower normalization; idempotence) → FAIL. (2) Implement. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~DeviceConfigIntentTests"` → PASS.
+**Commit:** `feat(commons): DeviceConfigIntent — device-host extraction + normalization home (R2-11)`
+
+### T5 — Composer swap: parse-once via `TagConfigIntent` (P-1 fix)
+
+**Classification:** structural (no behavior change — parity-guarded)
+**Estimated implement time:** 10 min
+**Parallelizable with:** — (first seam swap; blocks T6, T7, T10)
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`
+**Steps:** (1) In the equipment-tag `Select` (:417-433): `var intent = TagConfigIntent.Parse(t.TagConfig);`, project all six fields off `intent`, map `intent.Alarm` → `new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)`. (2) Delete `ExtractTagFullName`/`ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray` (:536-551, :601-686). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests"` → still PASS; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` → the three ExtractTag*Tests fail to compile — mark them for T10 (or convert in the same commit if trivial; keep the commit green by deleting their direct-static references now and relying on Commons.Tests, per T10).
+**Commit:** `refactor(opcuaserver): composer parses TagConfig once per tag via TagConfigIntent (R2-11, 01/P-1)`
+
+### T6 — Device-host re-home: composer statics → `DeviceConfigIntent`
+
+**Classification:** structural (no behavior change)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T7 (different files after T5)
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (:715, :742, :776), `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (:822-837)
+**Steps:** (1) Update the three cross-project callers to `DeviceConfigIntent.TryExtractHost`/`NormalizeHost`; update the :715 doc comment. (2) Delete `AddressSpaceComposer.TryExtractDeviceHost`/`NormalizeDeviceHost`. (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DeploymentArtifactDeviceHostParityTests"` → PASS.
+**Commit:** `refactor(runtime,opcuaserver): device-host normalization re-homed to Commons DeviceConfigIntent (R2-11)`
+
+### T7 — DeploymentArtifact swap
+
+**Classification:** structural (no behavior change — parity-guarded)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T6, T8, T9
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
+**Steps:** (1) In the tag-decode loop, call `TagConfigIntent.Parse` once per tag element; delete the four private mirrors (~:668-780). (2) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests|FullyQualifiedName~DeploymentArtifactHistorizeParityTests|FullyQualifiedName~DeploymentArtifactArrayParityTests|FullyQualifiedName~DeploymentArtifactAliasParityTests"` → all PASS.
+**Commit:** `refactor(runtime): DeploymentArtifact decodes TagConfig via shared TagConfigIntent (R2-11, 01/C-1)`
+
+### T8 — DraftValidator swap (+ Configuration → Commons reference)
+
+**Classification:** structural (no behavior change — T2 characterization guards)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T6, T7, T9
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj`, `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs`
+**Steps:** (1) Add `` to Commons. (2) Replace `ExtractTagConfigFullName(t.TagConfig)` with `TagConfigIntent.Parse(t.TagConfig).ExplicitFullName`; delete the private copy (:57-71). (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~DraftValidatorGalaxyFullNameCorpusTests"` → PASS (null-on-absent preserved).
+**Commit:** `refactor(config): DraftValidator Galaxy rule reads ExplicitFullName via TagConfigIntent (R2-11)`
+
+### T9 — Walker swap + tree-wide doc-comment repointing
+
+**Classification:** structural (no behavior change; dormant path)
+**Estimated implement time:** 5 min
+**Parallelizable with:** T6, T7, T8
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` (:193-208), plus doc-comment cites at `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:666`-area, `OpcUaClientTagConfigModel.cs:11`, `IScriptTagCatalog.cs`, `OtOpcUaNodeManager.cs` (grep `EquipmentNodeWalker.ExtractFullName` for the authoritative list)
+**Steps:** (1) `ExtractFullName` body → `TagConfigIntent.Parse(tagConfig).FullName` (Core already references Configuration; add Core → Commons `ProjectReference` if absent — verify). (2) Repoint every "canonical parser" doc cite to `TagConfigIntent.Parse`. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~EquipmentNodeWalkerFullNameCorpusTests"` → PASS. Note in the commit that full walker deletion remains round-1 U-1's task.
+**Commit:** `refactor(core): EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint canonical-parser doc cites (R2-11)`
+
+### T10 — Retire redundant `OpcUaServer.Tests/ExtractTag*Tests`
+
+**Classification:** test-only (cleanup; authority moved)
+**Estimated implement time:** 3 min
+**Parallelizable with:** T11
+**Files:** delete `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs`, `ExtractTagHistorizeTests.cs`, `ExtractTagArrayTests.cs`
+**Steps:** (1) Confirm every table row exists in `Commons.Tests/TagConfigIntentTests` (T3 ported them). (2) Delete; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` → PASS. (3) `grep -rn "MUST parse identically" src/` → **zero hits** (the comment-enforced contract is gone).
+**Commit:** `test(opcuaserver): retire ExtractTag* tests — byte-parity authority is Commons TagConfigIntentTests (R2-11)`
+
+### T11 — `TagConfigJson` strict-capable helpers in Core.Abstractions (TDD)
+
+**Classification:** structural (additive; nothing consumes yet)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T1–T4, T10
+**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs` (new)
+**Steps:** (1) Tests first: `TryReadEnum` Absent/Valid/Invalid (case-insensitive, matching the six copies' `ignoreCase: true`); `ReadEnumOrDefault` ≡ today's lenient semantics; `ReadWritable` explicit-false-only; `DescribeInvalidEnum` names field, value, and valid members → FAIL. (2) Implement per the Part 2 §1 API. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~TagConfigJsonTests"` → PASS.
+**Commit:** `feat(core-abstractions): TagConfigJson shared strict-capable field readers (R2-11, 05/CONV-2)`
+
+### T12 — Modbus parser: helper swap + `writable` + `Inspect`
+
+**Classification:** structural + behavior-frozen (freeze test first); `writable` is additive
+**Estimated implement time:** 10 min
+**Parallelizable with:** T13–T17, T19
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj`, `.../ModbusEquipmentTagParser.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** (1) Freeze test first: `{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}` still parses with `DataType == Int16` → PASS on unmodified tree. (2) Add Core.Abstractions reference; replace private `ReadEnum` (:65-67) with `TagConfigJson.ReadEnumOrDefault` for `region`/`dataType`/`byteOrder`; `Writable: TagConfigJson.ReadWritable(root)` (:57). (3) Add `Inspect(string reference)` → warnings for each invalid enum + unparseable-TagConfig. New tests: freeze still green; `Inspect` reports the `Intt16` typo; `writable:false` honoured; absent-writable ⇒ true. (4) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusEquipmentTagParserStrictnessTests"` FAIL→PASS.
+**Commit:** `feat(driver-modbus): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`
+
+### T13 — S7 parser: same shape as T12
+
+**Classification:** structural + behavior-frozen; `writable` additive
+**Estimated implement time:** 5 min
+**Parallelizable with:** T12, T14–T17
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj` (**amend the NO-ProjectReference banner** per Part 2 §1), `.../S7EquipmentTagParser.cs` (:34, :49, :59-61), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** as T12 (dataType only; keep the "node-level authz" comment updated to note the honoured key). Filter: `--filter "FullyQualifiedName~S7EquipmentTagParserStrictnessTests"` FAIL→PASS.
+**Commit:** `feat(driver-s7): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`
+
+### T14 — AbCip parser: helper swap + `Inspect` (writable already honoured)
+
+**Classification:** structural + behavior-frozen
+**Estimated implement time:** 5 min
+**Parallelizable with:** T12, T13, T15–T17
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/*.csproj`, `.../AbCipEquipmentTagParser.cs` (:45, :52-57, :86-88), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** as T12 minus the writable change (swap the hand-rolled :53-54 read to `TagConfigJson.ReadWritable` with a regression test that explicit-false semantics are bit-identical). FAIL→PASS.
+**Commit:** `feat(driver-abcip): shared TagConfigJson readers + Inspect warnings (R2-11)`
+
+### T15 — AbLegacy parser: same shape as T12
+
+**Classification:** structural + behavior-frozen; `writable` additive
+**Estimated implement time:** 5 min
+**Parallelizable with:** T12–T14, T16, T17
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/*.csproj`, `.../AbLegacyEquipmentTagParser.cs` (:41, :52, :60-62), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** as T12. FAIL→PASS.
+**Commit:** `feat(driver-ablegacy): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`
+
+### T16 — TwinCAT parser: same shape as T12
+
+**Classification:** structural + behavior-frozen; `writable` additive
+**Estimated implement time:** 5 min
+**Parallelizable with:** T12–T15, T17
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/*.csproj`, `.../TwinCATEquipmentTagParser.cs` (:31, :39, :47-49), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** as T12. FAIL→PASS.
+**Commit:** `feat(driver-twincat): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`
+
+### T17 — FOCAS parser: helper swap + **force `Writable:false`** + `Inspect`
+
+**Classification:** behavior-affecting (documented correction — 05/UNDER-1)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T12–T16
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj` (**amend banner**), `.../FocasEquipmentTagParser.cs` (:31, :35, :43-45), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs` (new)
+**Steps:** (1) Tests first: `Writable == false` regardless of `writable:true` (FAIL today); `Inspect` warns on `writable:true` ("FOCAS writes unsupported — forced read-only") and on invalid `dataType`. (2) Implement: `Writable: false` hard-coded with a doc comment citing `WireFocasClient.cs:71-73` + "honour the key when PMC writes ship"; helper swap. (3) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~FocasEquipmentTagParserStrictnessTests"` FAIL→PASS. Note the migration line for the docs task (T23).
+**Commit:** `fix(driver-focas): equipment tags forced read-only until PMC writes exist; shared readers + Inspect (R2-11, 05/UNDER-1)`
+
+### T18 — FOCAS capability gate on the equipment-tag resolve path
+
+**Classification:** behavior-affecting (documented correction — failure moves earlier + clearer)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T19
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (:69 `_parseRef` lambda; reuse :115-119 gate logic), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs` (new)
+**Steps:** (1) Test first: an equipment-tag ref whose address violates `FocasCapabilityMatrix` for the target device's series currently resolves (FAIL expected assertion) — assert it must NOT resolve (`TryResolve` false ⇒ read surfaces `BadNodeIdUnknown`). (2) Wrap `_parseRef`: after `FocasEquipmentTagParser.TryParse`, look up the device by `def.DeviceHostAddress`, run `FocasAddress.TryParse` + `FocasCapabilityMatrix.Validate`; return null on any rejection (resolver caches the negative). (3) FAIL→PASS: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~FocasEquipmentTagCapabilityGateTests"`.
+**Commit:** `fix(driver-focas): equipment tags run the capability-matrix pre-flight authored tags get (R2-11, 05/UNDER-6)`
+
+### T19 — Modbus probe parses the factory DTO shape
+
+**Classification:** behavior-affecting (probe-only; fixes silent `timeoutMs` drop)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T12–T18
+**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs` (:19-24, :36), the factory DTO in `ModbusDriverFactoryExtensions.cs` (extract a shared `TryParseConfig` if needed), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeParityTests.cs` (new)
+**Steps:** (1) Test first: a config with `"timeoutMs": 250` probe-parses with the 250 ms timeout (FAIL today — TimeSpan-typed runtime options drop it). (2) Rebind the probe to the factory DTO shape (OpcUaClient parity rule, `OpcUaClientDriverProbe.cs:22`). (3) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusDriverProbeParityTests"` FAIL→PASS.
+**Commit:** `fix(driver-modbus): probe parses the factory DTO shape — timeoutMs no longer silently dropped (R2-11, 05/CONV-2)`
+
+### T20 — `EquipmentTagConfigInspector` dispatch map in ControlPlane
+
+**Classification:** structural (additive; consumed in T21)
+**Estimated implement time:** 10 min
+**Parallelizable with:** T22
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj` (+6 Contracts references), `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs` (new), `tests/Server/` ControlPlane test project — mirror wherever `AdminOperationsActor` unit tests live (locate via `grep -rl AdminOperationsActor tests/`)
+**Steps:** (1) Tests first: known DriverType dispatches to the right `Inspect` (typo'd Modbus blob → one warning); unmapped (`GalaxyMxGateway`, `OpcUaClient`) → empty; case-insensitive keys matching `TagConfigValidator`'s canonical strings → FAIL. (2) Implement the static map (`"Modbus"/"S7"/"AbCip"/"AbLegacy"/"TwinCat"/"Focas"` → `EquipmentTagParser.Inspect`). (3) FAIL→PASS.
+**Commit:** `feat(controlplane): EquipmentTagConfigInspector — DriverType-dispatched deploy-time TagConfig inspection (R2-11)`
+
+### T21 — Deploy-gate wiring + `Deployment:TagConfigValidationMode` (Warn default | Error)
+
+**Classification:** behavior-affecting (flag-gated; Warn default = non-blocking)
+**Estimated implement time:** 15 min
+**Parallelizable with:** T22
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` (after :176), the actor's options/Props plumbing (follow the existing options pattern from Host), actor-level tests beside T20's
+**Steps:** (1) Tests first (in-memory draft, not DB-backed): Warn mode — typo'd equipment tag deploys **Accepted** with the warning in the log and appended to `StartDeploymentResult.Message`; Error mode — same draft **Rejected** with the text folded into the error summary; clean draft — no warnings; Galaxy tag — skipped → FAIL. (2) Wire: after the DraftValidator gate, run the inspector over `draft.Tags.Where(t => t.EquipmentId is not null)` joined to DriverType; branch on the option. **This actor-level consuming test is mandatory** (F10b lesson: an inspector built but never called is inert). (3) FAIL→PASS. Document the option in the appsettings sample.
+**Commit:** `feat(controlplane): deploy-gate TagConfig strictness — Warn default, Error opt-in at the draft gate (R2-11, 05/CONV-2)`
+
+### T22 — AdminUI: `writable` checkbox in the six typed tag editors
+
+**Classification:** mechanical (additive UI; requires live `/run` verify at execution — AdminUI has no bUnit)
+**Estimated implement time:** 15 min
+**Parallelizable with:** T20, T21
+**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas}TagConfigModel.cs` + matching `Components/Shared/Uns/TagEditors/*.razor`
+**Steps:** (1) Model unit tests first: `writable` round-trips `FromJson`/`ToJson` (camelCase, unknown-keys preserved, absent ⇒ null/omitted so existing configs are untouched) → FAIL. (2) Add the property + checkbox (default checked; FOCAS disabled-unchecked with hint). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~TagConfigModel"` (locate the exact model-test project via existing `ModbusTagConfigModel` tests) FAIL→PASS. (4) Flag: docker-dev `/run` verification of one editor (Modbus) required before merge — rebuild BOTH central-1/2.
+**Commit:** `feat(adminui): writable checkbox in driver-typed tag editors (R2-11, 05/UNDER-6)`
+
+### T23 — Docs + decision record
+
+**Classification:** docs-only
+**Estimated implement time:** 10 min
+**Parallelizable with:** T24 prep
+**Files:** `docs/Uns.md` (TagConfig keys: `writable`, validation modes), `docs/drivers/` FOCAS note (forced read-only migration line), `archreview/plans/STATUS.md` (R2-11 entry + the Phase-C runtime-strict follow-up), `archreview/plans/00-INDEX.md`
+**Steps:** Document the two-mode rollout decision (Part 2 §2 table), the unknown-keys non-goal, the FOCAS writable flip, and the Contracts-banner amendment rationale. No test.
+**Commit:** `docs: R2-11 TagConfig consolidation + strictness rollout decisions`
+
+### T24 — Final sweep + whole-solution gate
+
+**Classification:** verification
+**Estimated implement time:** 10 min (mostly test wall-time)
+**Parallelizable with:** —
+**Files:** none (verification only)
+**Steps:** (1) `grep -rn "MUST parse identically" src/` → 0 hits; `grep -rn "private static TEnum ReadEnum" src/` → 0 hits. (2) `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean. (3) `dotnet test ZB.MOM.WW.OtOpcUa.slnx` (integration suites need their fixtures per CLAUDE.md; unit suites must be green). (4) Confirm the parity corpus + all six freeze tests green — the byte-parity and lenient-runtime invariants held end-to-end.
+**Commit:** none (or squash-fixups); merge per branch strategy above.
+
+---
+
+## Dependency graph (summary)
+
+```
+T1, T2, T3, T4, T11 ─ independent starters
+T5  ← T1 + T3            T12–T17 ← T11 (mutually parallel)
+T6  ← T4 + T5            T18 ← T17
+T7  ← T5 (+T6 same file order)   T19 ← independent
+T8  ← T2 + T3            T20 ← T12–T17
+T9  ← T2 + T3            T21 ← T20
+T10 ← T3 + T5            T22 ← independent (models only)
+T23 ← T17 + T21          T24 ← everything
+```
diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
new file mode 100644
index 00000000..46bbe08a
--- /dev/null
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -0,0 +1,30 @@
+{
+  "planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
+  "lastUpdated": "2026-07-12",
+  "tasks": [
+    { "id": "T1",  "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "pending", "blockedBy": [] },
+    { "id": "T2",  "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "pending", "blockedBy": [] },
+    { "id": "T3",  "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] },
+    { "id": "T4",  "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] },
+    { "id": "T5",  "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] },
+    { "id": "T6",  "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
+    { "id": "T7",  "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
+    { "id": "T8",  "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] },
+    { "id": "T9",  "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "pending", "blockedBy": ["T2", "T3"] },
+    { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "pending", "blockedBy": ["T3", "T5"] },
+    { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "pending", "blockedBy": [] },
+    { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] },
+    { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "pending", "blockedBy": ["T17"] },
+    { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "pending", "blockedBy": [] },
+    { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
+    { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "pending", "blockedBy": ["T20"] },
+    { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "pending", "blockedBy": [] },
+    { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "pending", "blockedBy": ["T17", "T21"] },
+    { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "pending", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
+  ]
+}
diff --git a/archreview/plans/R2-12-repo-hygiene-plan.md b/archreview/plans/R2-12-repo-hygiene-plan.md
new file mode 100644
index 00000000..388bdc2d
--- /dev/null
+++ b/archreview/plans/R2-12-repo-hygiene-plan.md
@@ -0,0 +1,547 @@
+# Design + Implementation Plan — R2-12 Repo Hygiene Batch
+
+> **Status (2026-07-12):** PLANNED — nothing executed. This plan documents commands only; the
+> destructive steps (git rm of DLLs / planning files / workflow, rm -rf of husk dirs) **must not run
+> until their zero-reference proof step passes AND the maintainer signs off** (flagged per task).
+
+- **Source report:** `archreview/07-client-tooling-engineering.md` (2026-07-12 re-review; working-tree version)
+- **Overall context:** `archreview/00-OVERALL.md` — prioritized action item **#12** ("Hygiene: delete `lib/` AVEVA DLLs + Wonderware husks, untrack planning files, document 5 CLI commands, E2E workflow or delete it, dedupe CI integration matrix" — Source 07, Effort Small, Impact Medium — license risk + doc trust)
+- **Round-1 plan:** `archreview/plans/07-client-tooling-engineering-plan.md` (Tier B/C designs carried forward here)
+- **Verified against tree at:** master `f6eaa267` (clean tracked tree; archreview/*.md working-tree updates only)
+- **Scope:** `lib/`, retired `Driver.Historian.Wonderware*` disk husks, 5 root planning files, `docs/Client.CLI.md`, `.github/workflows/v2-e2e.yml` + `v2-ci.yml`, `StyleGuide.md` + `.editorconfig`, the 3 xunit 2.9.2 holdouts. Items: **07/U-5, 07/U-6, 07/U-4, 07/U-3, 07/S-3, 07/P-5 (new), 07/C-5 (StyleGuide + editorconfig; the R2-12 brief labels this C-6), 07/C-4**. 07/U-9 is already done (see below).
+
+## Verification summary
+
+Every item was re-verified directly against the tree at `f6eaa267` on 2026-07-12. **All items
+CONFIRMED still present except U-9 (already fixed in the working tree).** Exact results:
+
+- **U-5 CONFIRMED** — `git ls-files lib/` returns exactly 7 tracked binaries:
+  `lib/ArchestrA.CloudHistorian.Contract.dll`, `lib/ArchestrA.MXAccess.dll`, `lib/Historian.CBE.dll`,
+  `lib/Historian.DPAPI.dll`, `lib/aahClient.dll`, `lib/aahClientCommon.dll`, `lib/aahClientManaged.dll`.
+  A per-filename grep of every `*.csproj`/`*.props`/`*.targets`/`*.slnx` in the repo returns **zero
+  hits for all 7 names**, and there is **not a single `` element anywhere in the repo**
+  (`grep -rn "HintPath" --include='*.csproj' --include='*.props' --include='*.targets' .` → empty).
+  Zero references, proven.
+- **U-6 CONFIRMED** — 5 retired directories exist on disk:
+  `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware{,.Client,.Client.Contracts}` and
+  `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware{.Tests,.Client.Tests}`. Each contains
+  **only `bin/` + `obj/`** (no source, no csproj). `git ls-files` over all 5 paths returns **0 files**
+  (they are untracked, gitignored bin/obj husks). `grep -i wonderware ZB.MOM.WW.OtOpcUa.slnx` → no
+  match (exit 1); zero `Historian.Wonderware` hits in any csproj. Tracked Wonderware residue is only
+  the intentional retired stub `docs/drivers/Historian.Wonderware.md` (keep — CLAUDE.md points at it)
+  and 3 `code-reviews/Driver.Historian.Wonderware*/findings.md` (keep — closed-review history).
+- **U-4 CONFIRMED** — `git ls-files` shows all 5 tracked at root: `pending.md` (71,575 B, "As of
+  2026-06-16"), `current.md` (8,040 B, "Updated 2026-06-13"), `looseends.md` (2,817 B, 2026-05-18),
+  `stillpending.md` (22,491 B, reconciled 2026-06-18), `HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md`
+  (11,528 B, dated 2026-06-27). `pending.md` line 3 still carries the hard rule "never stage
+  `pending.md` / `current.md`". `.gitignore` has **no** entry for any of them (nor for `lib/`).
+  One tracked cross-reference exists: `docs/Historian.md:397-399` cites `stillpending.md` by name.
+- **U-3 CONFIRMED (count corrected)** — the CLI has **13** commands, not the report's 14:
+  `[Command("…")]` attributes in `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/` are exactly
+  `ack, alarms, browse, confirm, connect, disable, enable, historyread, read, redundancy, shelve,
+  subscribe, write` (13 files, 13 attributes). `docs/Client.CLI.md` has `###` sections for 8:
+  connect, read, write, browse, subscribe, historyread, alarms, redundancy. **Missing: `ack`,
+  `confirm`, `shelve`, `enable`, `disable`** — the full operator alarm workflow. Options verified
+  from source (see item 4).
+- **S-3 CONFIRMED** — `grep -rn 'Category.*E2E' tests/ --include='*.cs'` → zero matches (exit 1).
+  `v2-e2e.yml` header (lines 6-10) itself admits "this workflow is a green no-op". It also boots the
+  docker-dev fleet on a hosted runner — a fleet that per CLAUDE.md binds the **shared GLAuth at
+  `10.100.0.35:3893`** (LAN-only) and builds locally-defined images, so it is not
+  hosted-runner-bootable even if E2E tests existed.
+- **P-5 CONFIRMED** — `v2-ci.yml:77-106`: the `integration` matrix re-restores, re-builds, and
+  re-runs `Host.IntegrationTests` + `OpcUaServer.IntegrationTests` under the **identical filter**
+  (`Category!=E2E&Category!=LiveIntegration`) the whole-solution `unit-tests` leg (lines 58-62)
+  already applies to both projects, fixtureless both times, with a report-only (`MIN_EXECUTED=0`)
+  gate whose tally the unit leg's trx already contains. Fully redundant.
+- **C-5 CONFIRMED (path corrected)** — the file is at repo root **`StyleGuide.md`** (not
+  `docs/StyleGuide.md` as the report cites); line 3 reads "…for all ScadaBridge documentation".
+  No `.editorconfig` exists anywhere (`git ls-files '*.editorconfig'` → empty).
+- **C-4 CONFIRMED (with a load-bearing wrinkle)** — `Directory.Packages.props:108` pins
+  `xunit` 2.9.2; exactly 3 csprojs reference it (`AdminUI.Tests`, `ControlPlane.Tests`,
+  `Runtime.Tests`; 44 projects are on `xunit.v3` 1.1.0). **All three holdout csprojs carry an
+  explicit comment: `Akka.TestKit.Xunit2` ties them to xunit v2 ("TestKit isn't ported to v3
+  yet").** Akka is pinned at 1.5.62. So this is an upstream-blocked migration, not a mechanical
+  version alignment — see item 8 for the two-branch plan.
+- **U-9 ALREADY DONE — no task.** `archreview/plans/STATUS.md` in the working tree (modified,
+  uncommitted, per `git status`) now reads "Branch topology (HISTORICAL — as of 2026-07-08; since
+  merged + pushed)" (line 13), carries the "2026-07-12 UPDATE — … merged to master and pushed"
+  banner (lines 6-9), and strikes through "Nothing is pushed" (line 116). The stale claims are
+  corrected; committing the working-tree archreview updates ships it. **Mark done; do not re-plan.**
+
+---
+
+## Priority ordering
+
+All items are Small; ordering is by risk-class, destructive-first-with-proof:
+
+1. **U-5** — tracked proprietary binaries (license risk on every clone) — the one item with legal weight.
+2. **U-4** — tracked planning files violating their own rule (trust/doc drift).
+3. **S-3 + P-5** — CI truth fixes (one workflow file deleted, one job deleted).
+4. **U-3** — operator-facing doc gap.
+5. **C-5 + editorconfig** — trivial polish.
+6. **C-4** — conditional on upstream; smallest honest fix if blocked.
+7. **U-6** — untracked disk-only cleanup (no commit at all).
+
+---
+
+## 1. 07/U-5 — 7 orphaned proprietary AVEVA DLLs tracked in `lib/`
+
+**Restatement:** 7 committed AVEVA/Wonderware vendor binaries sit in `lib/`, referenced by no
+project — a redistribution/licence risk shipped in every clone. Leftovers from the retired
+in-process MXAccess / Wonderware-sidecar era; the COM/bitness story now lives entirely in the
+sibling `mxaccessgw` repo.
+
+**Verification:** Confirmed (see summary). Zero-reference proof already executed read-only:
+per-filename grep over all msbuild files + slnx = 0 hits; zero `HintPath` elements repo-wide.
+
+**Proposed action — DELETE (`git rm -r lib/`) + gitignore guard.** There is no keep case: nothing
+compiles against them, the gateway repo owns the MXAccess surface, and the binaries remain
+retrievable from git history or the AVEVA SDK if ever needed. Add `lib/` to `.gitignore` to block
+re-introduction. **Explicitly out of scope:** purging them from *history* (BFG/filter-repo) is a
+separate, heavier decision that rewrites every clone; the immediate licence-hygiene win is removing
+them from the tip. Note this in the commit message.
+
+> **⚠️ MAINTAINER SIGN-OFF REQUIRED before the `git rm` runs.** The executor must re-run the proof
+> (step 1) and show its empty output; delete only after the proof passes and the maintainer approves.
+
+**Steps (exact commands):**
+
+1. **Proof (must pass first — expect zero output from both):**
+   ```bash
+   for dll in aahClient.dll aahClientCommon.dll aahClientManaged.dll \
+              ArchestrA.CloudHistorian.Contract.dll ArchestrA.MXAccess.dll \
+              Historian.CBE.dll Historian.DPAPI.dll; do
+     grep -rln "$dll" --include='*.csproj' --include='*.props' --include='*.targets' --include='*.slnx' .
+   done
+   grep -rn "HintPath" --include='*.csproj' --include='*.props' --include='*.targets' .
+   ```
+   Expected: no output (verified 2026-07-12: no output).
+2. `git rm -r lib/` — expect `rm 'lib/…'` × 7.
+3. Append to `.gitignore` under a new `# Vendor binaries — never commit (license risk; see R2-12/U-5)` header: `lib/`
+4. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — must succeed (proves nothing linked them).
+5. Commit (see Task breakdown, commit 1).
+6. Verify: `git ls-files lib/` → empty; `git status` clean.
+
+**Effort:** Trivial. **Risk:** Low — zero-reference proven; blast radius nil at the tip. The only
+residual is history retention (documented as out of scope).
+
+---
+
+## 2. 07/U-6 — retired `Historian.Wonderware*` husk directories on disk
+
+**Restatement:** 5 retired `Driver.Historian.Wonderware*` directories linger under `src/Drivers/` +
+`tests/Drivers/` after the HistorianGateway cutover retired the Wonderware backend.
+
+**Verification:** Confirmed — but they are **untracked, gitignored `bin/`+`obj/` husks only**
+(0 tracked files across all 5; absent from the slnx; zero csproj references). This is a disk-only
+cleanup: **no git command, no commit** results from it.
+
+**Proposed action — plain `rm -rf` of the 5 directories.** Keep the two classes of *tracked*
+Wonderware residue: `docs/drivers/Historian.Wonderware.md` (deliberate retired stub, referenced by
+CLAUDE.md) and the 3 `code-reviews/Driver.Historian.Wonderware*/findings.md` (closed-review
+history). The scope brief's "verify absent from slnx + zero project references, then delete" is
+satisfied: the projects were properly `git rm`'d in the retirement PR; only ignored build output
+survived on this machine.
+
+> **⚠️ Sign-off note:** destructive to local disk only (build artifacts). Proof step must still show
+> "0 tracked files" before `rm -rf` runs.
+
+**Steps (exact commands):**
+
+1. **Proof (expect `0` and no slnx match):**
+   ```bash
+   git ls-files src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware* \
+                tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware* | wc -l   # → 0
+   grep -i wonderware ZB.MOM.WW.OtOpcUa.slnx                                           # → exit 1
+   ```
+2. ```bash
+   rm -rf src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware \
+          src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client \
+          src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts \
+          tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests \
+          tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests
+   ```
+3. Verify: `find src tests -maxdepth 3 -type d -iname '*Wonderware*'` → empty;
+   `git status` **unchanged** by the deletion (they were untracked).
+
+**Effort:** Trivial. **Risk:** None (ignored build output; regenerable would-be, except the
+projects no longer exist to regenerate it — which is the point).
+
+---
+
+## 3. 07/U-4 — tracked root planning files contradict their own "never stage" rule
+
+**Restatement:** 5 point-in-time planning/working-notes files are committed at root; `pending.md:3`
+itself declares the hard rule "never stage `pending.md` / `current.md`". All are stale snapshots
+that now contradict `docs/` + CLAUDE.md (e.g. `pending.md` is "as of 2026-06-16", ~25 commits and
+one whole backend cutover ago).
+
+**Verification:** Confirmed — all 5 tracked; no `.gitignore` entries; the one inbound tracked
+reference is `docs/Historian.md:397,399` → `stillpending.md`.
+
+**Proposed action — per-file (contents were read; recommendations follow from content):**
+
+| File | Content finding | Recommendation | Rationale |
+|---|---|---|---|
+| `pending.md` | Active session-notes ledger (71 KB); carries the "never stage" hard rule itself | **Untrack, keep locally:** `git rm --cached pending.md` + gitignore | Makes the file's own rule true without destroying live working notes |
+| `current.md` | Galaxy standard-driver status "Updated 2026-06-13" — that effort fully shipped (Phases A/B/C on master; recorded in CLAUDE.md) | **Untrack, keep locally:** `git rm --cached current.md` + gitignore | Named by the same hard rule; content stale but it's a working-notes file by declared convention |
+| `looseends.md` | 2026-05-18 snapshot; sole open item "#20 dev-rig rollout smoke" targets the **retired Wonderware sidecar rig** — moot since the gateway cutover | **Delete outright:** `git rm looseends.md` | Fully superseded; its one open item is against infrastructure that no longer exists |
+| `stillpending.md` | The reconciled 2026-06-18 backlog audit (~95% shipped); **referenced by tracked `docs/Historian.md:397-399`** and by the memory index | **Archive-move:** `git mv stillpending.md docs/plans/2026-06-18-stillpending-backlog-audit.md` + update the `docs/Historian.md` reference | Referenced history — preserve under the established dated-plan convention rather than delete |
+| `HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md` | 2026-06-27 live-smoke findings that drove the provisioning-dormancy fix (file/line refs pinned to `245316d8`) | **Archive-move:** `git mv HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md docs/plans/2026-06-27-historian-gateway-integration-issues.md` | Referenced investigative history (memory index cites it); dated-plan archive fits |
+
+> **⚠️ MAINTAINER SIGN-OFF REQUIRED for the `looseends.md` deletion** (only truly destructive step —
+> `git rm --cached` keeps local copies, `git mv` keeps content). The executor must show the
+> maintainer this table before running step 2.
+
+**Steps (exact commands):**
+
+1. ```bash
+   git rm --cached pending.md current.md          # untrack, keep on disk
+   ```
+2. ```bash
+   git rm looseends.md                            # delete (sign-off required)
+   git mv stillpending.md docs/plans/2026-06-18-stillpending-backlog-audit.md
+   git mv HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md docs/plans/2026-06-27-historian-gateway-integration-issues.md
+   ```
+3. Append to `.gitignore` under `# Session working notes — never stage (pending.md's own hard rule)`:
+   ```
+   /pending.md
+   /current.md
+   ```
+   (root-anchored so a future `docs/plans/*pending*` archive is not swallowed).
+4. Edit `docs/Historian.md:397-399`: repoint the two `stillpending.md` mentions at
+   `docs/plans/2026-06-18-stillpending-backlog-audit.md`.
+5. Verify:
+   ```bash
+   git ls-files | grep -E '^(pending|current|looseends|stillpending|HISTORIAN)'   # → empty
+   git status --short | grep -E 'pending|current'                                # → nothing staged; files still on disk
+   git grep -n 'stillpending\.md' -- docs/ CLAUDE.md                             # → only the archived path
+   ```
+
+**Effort:** Small. **Risk:** Low — pure VCS hygiene; `--cached` + `mv` preserve all content; the one
+deletion is a 2.8 KB fully-superseded snapshot recoverable from history.
+
+---
+
+## 4. 07/U-3 — `docs/Client.CLI.md` documents 8 of 13 commands
+
+**Restatement:** the five operator alarm commands (`ack`, `confirm`, `shelve`, `enable`, `disable`)
+ship in the CLI but have zero sections in `docs/Client.CLI.md`, while CLAUDE.md points operators at
+that doc. (Correction to the report: the actual command set is **13**, not 14 — verified by
+enumerating `[Command("…")]` attributes; 8 documented + 5 missing = 13.)
+
+**Verification:** Confirmed. Enumerated command set vs `###` headings (see summary). Options
+extracted from the command sources:
+
+| Command | Options (from source) |
+|---|---|
+| `ack` (`AcknowledgeCommand.cs`) | `--node/-n` condition NodeId (required) · `--event-id/-e` hex EventId (required) · `--comment/-c` |
+| `confirm` (`ConfirmCommand.cs`) | `--node/-n` (required) · `--event-id/-e` (required) · `--comment/-c` — two-stage ack |
+| `shelve` (`ShelveCommand.cs`) | `--node/-n` (required) · `--kind/-k` `OneShot \| Timed \| Unshelve` (required) · `--duration/-d` seconds (required for `Timed`; converted to ms for the OPC UA call) |
+| `enable` (`EnableCommand.cs`) | `--node/-n` (required) |
+| `disable` (`DisableCommand.cs`) | `--node/-n` (required) |
+
+All five inherit `CommandBase` connection options (`-u`, auth, security, failover) like the
+documented commands.
+
+**Proposed action — exactly these docs sections to add** to `docs/Client.CLI.md`, inserted after the
+existing `### alarms` section (they are its operator-action counterparts) and before `### redundancy`:
+
+1. A grouping intro `### Alarm operator commands (ack / confirm / shelve / enable / disable)` **or**
+   five flat `###` sections mirroring the existing per-command shape — prefer **five flat `###`
+   sections** (`### ack`, `### confirm`, `### shelve`, `### enable`, `### disable`) to match the
+   established one-heading-per-command structure, each with: synopsis line, options table, one
+   example invocation, expected output. Note in each that the server gates these on the **AlarmAck**
+   LDAP role (per CLAUDE.md §Scripted Alarm Ack/Shelve) and that `--event-id` comes from a prior
+   `alarms` subscription notification.
+2. Cross-check terminology against `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve".
+3. Scan the doc's Overview/intro for any stale command-count claim ("8 commands"/"14 commands") and
+   correct to 13.
+
+**Steps:** edit `docs/Client.CLI.md`; verify each command's documented options against
+`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI --  --help` output (or the
+source attributes above, if not running the CLI).
+
+**Effort:** Small. **Risk:** None (docs only).
+
+---
+
+## 5. 07/S-3 — `v2-e2e.yml` gates zero tests (permanent green no-op)
+
+**Restatement:** the nightly workflow boots the docker-dev fleet then runs
+`dotnet test --filter "Category=E2E"` — a filter that matches zero tests in the tree — and reports
+green daily, training people to ignore the badge.
+
+**Verification:** Confirmed — zero `Category=E2E` traits repo-wide (`grep -rn 'Category.*E2E'
+tests/ --include='*.cs'` → exit 1); the workflow header self-documents the no-op.
+
+**Proposed action — DELETE the workflow (recommended), not seed a test.** Rationale:
+
+- **The fleet cannot boot on a hosted runner anyway.** `docker-dev/docker-compose.yml` binds the
+  shared GLAuth at `10.100.0.35:3893` (LAN-only, per CLAUDE.md) and builds locally-defined
+  `otopcua-*` images — so even a seeded E2E test would fail-or-flake at the "Build docker-dev
+  fleet" / "Wait for cluster" steps on GitHub/Gitea hosted runners. Seeding one real E2E test does
+  **not** make this workflow honest; it makes it red-for-infrastructure-reasons, which is worse
+  than absent.
+- **The false-green cost is live today**; deletion removes it immediately at zero effort.
+- **Nothing of value is lost.** The whole-solution CI leg (07/S-1, shipped) genuinely gates all 47
+  test projects; the `scripts/e2e/test-*.ps1` harnesses (the real E2E assets) are untouched. When
+  a real `E2ETests` project lands (the round-1 plan's option (b), still the right follow-on), it
+  should arrive **with its own workflow** including a zero-match guard (fail if `Category=E2E`
+  matches nothing) and a runner that can actually reach a fleet — a fresh design, not this husk.
+- Alternative (round-1 option (a): keep the file, comment out `schedule:`, add a zero-match guard)
+  is acceptable if the maintainer wants the file as a placeholder — but a placeholder that cannot
+  run on its own runner documents an intention better served by one line in a tracked follow-up.
+
+> **⚠️ Maintainer sign-off:** deleting a workflow file — confirm the nightly's absence won't be
+> mistaken for a CI regression (it will simply stop appearing in Actions).
+
+**Steps:**
+```bash
+git rm .github/workflows/v2-e2e.yml
+```
+Record the follow-up ("land tests/Server/…E2ETests + a new workflow with a zero-match guard when a
+runner-reachable fleet exists") in the commit message and in `archreview/plans/STATUS.md`'s open
+list. Also remove the now-dangling pointer in `v2-ci.yml`'s header comment (line 7-8: "Skips E2E …
+that runs nightly via v2-e2e.yml") — fold into the P-5 edit below.
+
+**Effort:** Trivial. **Risk:** Low — removes a job that has never executed a test.
+
+---
+
+## 6. 07/P-5 (new) — CI integration matrix fully redundant with the unit leg
+
+**Restatement:** the `integration` matrix job (`v2-ci.yml:77-106`) re-restores, re-builds, and
+re-runs `Host.IntegrationTests` + `OpcUaServer.IntegrationTests` under the identical filter the
+whole-solution `unit-tests` leg already applies to the same two projects, fixtureless both times.
+Its only unique output is a per-project skip tally the unit leg's trx already contains. (Note the
+projects are not pure-skip — the in-process 2-node Akka harness tests, e.g. `HardKillFailoverTests`,
+genuinely execute — but they execute in **both** jobs.)
+
+**Verification:** Confirmed at `v2-ci.yml:58-62` (unit leg, whole slnx, same filter) vs `:77-106`
+(matrix, same filter, `MIN_EXECUTED=0` report-only gate).
+
+**Proposed action — DELETE the `integration` job (recommended).** The report offers two options:
+delete, or make it earn its keep by adding the `mcr.microsoft.com/iotedge/opc-plc` `services:`
+block + strict gate (S-2 option #2). Recommend **delete** for this batch because:
+
+- The matrixed projects don't consume `OPCUA_SIM_ENDPOINT` — the opc-plc fixture serves the
+  OpcUaClient *driver* integration suite, which runs in the unit leg, not in this matrix. Making
+  this specific matrix "earn its keep" therefore means redesigning *which* projects it runs — a
+  real S-2-completion work item, not a dedupe.
+- Deletion halves the remaining CI restore/build waste for free (per 07/P-1) and removes a
+  duplicate execution of the slowest harness tests on every push.
+- The S-2 option-#2 follow-up (a fixture-backed leg with a strict gate, covering something the unit
+  leg cannot) stays tracked in the workflow's own comment lineage — preserve it in the commit
+  message and STATUS.md rather than in dead YAML.
+
+**Steps:**
+1. Edit `.github/workflows/v2-ci.yml`: delete the entire `integration:` job (lines 77-106) and the
+   header-comment line describing it (line 5), and update the header's E2E pointer (line 7-8) per
+   S-3 above. Keep `build` and `unit-tests` untouched.
+2. Verify YAML: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/v2-ci.yml'))"`
+   (or `yq`), then confirm on the first post-push run that `build` → `unit-tests` still gate.
+
+**Effort:** Trivial. **Risk:** Low — no coverage is lost (both projects still run in the unit leg
+under the strict `MIN_EXECUTED=1` gate); only duplicate execution is removed.
+
+---
+
+## 7. 07/C-5 — `StyleGuide.md` says "ScadaBridge"; no repo `.editorconfig`
+
+*(The R2-12 brief labels this C-6; in the source report the StyleGuide + editorconfig finding is
+**C-5** — C-6 is csproj-boilerplate dedup, out of scope here.)*
+
+**Restatement:** the documentation style guide still carries the sister repo's product name; there
+is no mechanical code-style anchor at all.
+
+**Verification:** Confirmed — the file is at repo root **`StyleGuide.md`** (the report's
+`docs/StyleGuide.md` path is wrong; noted for the next report pass), line 3: "This guide defines
+writing conventions and formatting rules for all ScadaBridge documentation." No `.editorconfig`
+tracked anywhere.
+
+**Proposed action — the minimal fix, per the brief:**
+1. `StyleGuide.md`: replace "ScadaBridge" → "OtOpcUa" on line 3, then
+   `grep -n "ScadaBridge" StyleGuide.md` and fix any further hits (verified: line 3 is in the head;
+   sweep the whole file at execution time).
+2. Add a **minimal root `.editorconfig`** codifying the already-consistent conventions at
+   non-error severity (documentation-of-convention, not a reformat mandate — no `dotnet format`
+   CI step in this batch):
+   ```ini
+   root = true
+
+   [*]
+   charset = utf-8
+   insert_final_newline = true
+   indent_style = space
+
+   [*.{cs,csproj,props,targets}]
+   indent_size = 4
+
+   [*.cs]
+   csharp_style_namespace_declarations = file_scoped:suggestion
+   dotnet_naming_rule.private_fields_underscore.severity = suggestion
+   dotnet_naming_rule.private_fields_underscore.symbols = private_fields
+   dotnet_naming_rule.private_fields_underscore.style = underscore_camel
+   dotnet_naming_symbols.private_fields.applicable_kinds = field
+   dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+   dotnet_naming_style.underscore_camel.capitalization = camel_case
+   dotnet_naming_style.underscore_camel.required_prefix = _
+
+   [*.{yml,yaml,json}]
+   indent_size = 2
+   ```
+3. Verify: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — the build must be **warning-delta-free** (all
+   severities `suggestion`; if any new warning appears in a `TreatWarningsAsErrors` project, drop
+   that rule to `suggestion` — nothing in this batch may fail a TWE build).
+
+**Effort:** Trivial. **Risk:** Low — the one hazard is `.editorconfig` rules surfacing as warnings
+in TWE'd projects; mitigated by suggestion-only severities + the build check.
+
+---
+
+## 8. 07/C-4 — 3 xunit 2.9.2 holdout projects
+
+**Restatement:** `AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests` still reference `xunit`
+2.9.2 while 44 projects are on `xunit.v3` — a mixed v2/v3 population as a moving part in the new
+whole-solution CI leg.
+
+**Verification:** Confirmed — but with a decisive wrinkle the report undersells: **all three csprojs
+carry an explicit comment that `Akka.TestKit.Xunit2` pins them to xunit v2** ("TestKit isn't ported
+to v3 yet"). Akka is pinned at 1.5.62. The holdouts are not drift; they are a documented upstream
+block, discoverable only at the csproj level today.
+
+**Proposed action — two-branch, decided by a feed probe (exact commands below):**
+
+- **Branch A (unblocked):** if an xunit-v3-compatible Akka TestKit now exists at/near Akka 1.5.62
+  (candidate IDs: `Akka.TestKit.Xunit3`, `Akka.TestKit.Xunit.v3`), migrate all 3 projects:
+  swap `Akka.TestKit.Xunit2`→the v3 package + `xunit`→`xunit.v3` in each csproj, fix any
+  `Akka.TestKit.Xunit2` namespace usings, run each suite, then **delete the `xunit` 2.9.2 pin**
+  from `Directory.Packages.props:108`. Effort M if taken — spill to a follow-up PR if test churn
+  exceeds trivial.
+- **Branch B (still blocked — expected):** do the honest CPM alignment instead: (1) bump the pin to
+  the latest 2.9.x patch (`2.9.3`) if the feed has it; (2) **add the blocker rationale as a comment
+  in `Directory.Packages.props`** next to line 108, house-style (mirroring the CVE-suppress
+  carve-out): `` — so the removal condition lives at the pin, not just in
+  3 scattered csprojs.
+
+**Steps:**
+1. Probe:
+   ```bash
+   for id in Akka.TestKit.Xunit3 Akka.TestKit.Xunit.v3; do
+     curl -s "https://api.nuget.org/v3-flatcontainer/$(echo $id | tr 'A-Z' 'a-z')/index.json" | head -c 400; echo;
+   done
+   curl -s "https://api.nuget.org/v3-flatcontainer/xunit/index.json" | python3 -c "import json,sys; print([v for v in json.load(sys.stdin)['versions'] if v.startswith('2.9')])"
+   ```
+2. Take Branch A or B accordingly.
+3. Verify (either branch): `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests
+   tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests`
+   all green; whole-solution build clean.
+
+**Effort:** Small (Branch B) / Medium (Branch A — cap it: if migration churn is non-trivial, ship
+Branch B now and file the migration as a follow-up). **Risk:** Low (B) / Medium (A — TestKit swap
+touches Akka actor tests; run all three suites).
+
+---
+
+## 9. 07/U-9 — STATUS.md stale "nothing pushed" claim — **ALREADY DONE**
+
+Fixed in the working tree on 2026-07-12 (verified: topology header marked HISTORICAL, merge/push
+banner added, resume-facts struck through and superseded). No task in this plan; it ships when the
+working-tree archreview updates are committed. **Do not re-plan.**
+
+---
+
+## Task breakdown
+
+Execution note: **T1/T3a/T5a/T9a are the zero-reference/probe proof gates — each destructive task is
+blocked on its proof AND on maintainer sign-off.** Suggested commit grouping: 4 commits (removals /
+planning files / CI workflows / docs+style) + 1 conditional (xunit).
+
+### T1 — U-5 proof: zero references to the 7 `lib/` DLLs
+**Classification:** trivial · **Estimated implement time:** 5 min · **Parallelizable with:** T3a, T5a, T6-pre, T9a · **Files:** none (read-only)
+Run the two greps from item 1 step 1. Expected output: **empty** (verified empty 2026-07-12).
+Then `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → success. Present the empty output to the maintainer
+for sign-off on T2.
+
+### T2 — U-5: `git rm -r lib/` + gitignore guard  *(blocked by T1 + sign-off)*
+**Classification:** trivial · **Estimated implement time:** 5 min · **Parallelizable with:** none (commit 1) · **Files:** `lib/*` (delete ×7), `.gitignore`
+`git rm -r lib/` (expect 7 `rm` lines) · append `lib/` to `.gitignore` · rebuild →
+**Commit 1:** `chore(hygiene): remove 7 orphaned proprietary AVEVA DLLs from lib/ (07/U-5)` — note
+zero-reference proof + history-purge-out-of-scope in the body. Verify `git ls-files lib/` → empty.
+
+### T3a — U-6 proof: husk dirs are untracked and slnx-absent
+**Classification:** trivial · **Estimated implement time:** 2 min · **Parallelizable with:** T1, T5a, T9a · **Files:** none (read-only)
+Run item 2 step 1. Expected: `0` tracked files; slnx grep exit 1 (verified 2026-07-12).
+
+### T3b — U-6: delete the 5 Wonderware husk directories  *(blocked by T3a)*
+**Classification:** trivial · **Estimated implement time:** 2 min · **Parallelizable with:** any (no git change) · **Files:** the 5 untracked dirs (disk only)
+`rm -rf` per item 2 step 2. Verify `find src tests -maxdepth 3 -type d -iname '*Wonderware*'` →
+empty and `git status` unchanged. **No commit.**
+
+### T5a — U-4 proof + per-file confirmation
+**Classification:** trivial · **Estimated implement time:** 5 min · **Parallelizable with:** T1, T3a, T9a · **Files:** none (read-only)
+`git ls-files | grep -E '^(pending|current|looseends|stillpending|HISTORIAN)'` → the 5 files
+(verified). Re-read `looseends.md` to confirm nothing new was added since 2026-05-18; show the
+maintainer the item-3 recommendation table for sign-off (especially the `looseends.md` delete).
+
+### T5b — U-4: untrack / archive / delete + gitignore + doc repoint  *(blocked by T5a + sign-off)*
+**Classification:** small · **Estimated implement time:** 15 min · **Parallelizable with:** T6/T7 (different files) · **Files:** the 5 root files, `.gitignore`, `docs/Historian.md`
+Run item 3 steps 1-4 exactly. **Commit 2:** `chore(hygiene): untrack/archive root planning files;
+honor pending.md's never-stage rule (07/U-4)`. Verify per item 3 step 5.
+
+### T6 — S-3: delete `v2-e2e.yml`  *(sign-off required)*
+**Classification:** trivial · **Estimated implement time:** 5 min · **Parallelizable with:** T5b, T8, T9b · **Files:** `.github/workflows/v2-e2e.yml` (delete)
+Pre-proof: `grep -rn 'Category.*E2E' tests/ --include='*.cs'` → exit 1 (verified). Then
+`git rm .github/workflows/v2-e2e.yml`. Folded into **Commit 3** with T7. Record the
+E2ETests-with-zero-match-guard follow-up in the commit body + STATUS.md open list.
+
+### T7 — P-5: remove the redundant `integration` matrix job from `v2-ci.yml`  *(pairs with T6)*
+**Classification:** small · **Estimated implement time:** 10 min · **Parallelizable with:** T5b, T8, T9b · **Files:** `.github/workflows/v2-ci.yml`
+Delete the `integration:` job (`v2-ci.yml:77-106`) + header lines referencing it and the deleted
+nightly (lines 5, 7-8). YAML-validate. **Commit 3:** `ci(hygiene): delete no-op nightly E2E workflow
++ redundant integration matrix (07/S-3, 07/P-5)` — body notes the S-2 option-#2 fixture-leg
+follow-up. Verify on first post-push run: `build` → `unit-tests` green, no `integration` job.
+
+### T8 — U-3: add the 5 missing CLI command sections
+**Classification:** small · **Estimated implement time:** 30 min · **Parallelizable with:** T6, T7, T9b, T10 · **Files:** `docs/Client.CLI.md`
+Add `### ack`, `### confirm`, `### shelve`, `### enable`, `### disable` after `### alarms`, per the
+item-4 options table (synopsis + options + example + AlarmAck-role note + event-id-from-alarms
+note); fix any stale command-count claim to 13. Cross-check `docs/ScriptedAlarms.md` terminology.
+Part of **Commit 4**.
+
+### T9a — C-5 proof: ScadaBridge sweep + editorconfig absence
+**Classification:** trivial · **Estimated implement time:** 2 min · **Parallelizable with:** T1, T3a, T5a · **Files:** none (read-only)
+`grep -n "ScadaBridge" StyleGuide.md` (expect line 3; note any others) ·
+`git ls-files '*.editorconfig'` → empty (verified).
+
+### T9b — C-5: StyleGuide fix + minimal `.editorconfig`  *(blocked by T9a)*
+**Classification:** trivial · **Estimated implement time:** 15 min · **Parallelizable with:** T8, T10 · **Files:** `StyleGuide.md`, new `.editorconfig`
+Apply item 7 steps 1-2; rebuild and confirm zero new warnings in TWE'd projects. With T8 as
+**Commit 4:** `docs(hygiene): document 5 alarm operator CLI commands; fix StyleGuide product name;
+add minimal .editorconfig (07/U-3, 07/C-5)`.
+
+### T10 — C-4: xunit holdouts — probe, then migrate or document the pin
+**Classification:** small (Branch B) / medium (Branch A) · **Estimated implement time:** 20 min (B) / 2 h (A) · **Parallelizable with:** T8, T9b · **Files:** `Directory.Packages.props`; Branch A also the 3 holdout csprojs + their test sources
+Run the item-8 probe. Branch B (expected): bump pin to latest 2.9.x + add the blocker comment at
+`Directory.Packages.props:108`; run the 3 suites. Branch A: migrate per item 8 (cap effort; spill
+to a follow-up if churn is non-trivial). **Commit 5:** `test(hygiene): document/align the xunit v2
+holdout pin (07/C-4)` (or the migration equivalent). Verify: 3 suites green + whole-solution build.
+
+### T11 — Final verification pass  *(blocked by all above)*
+**Classification:** small · **Estimated implement time:** 15 min · **Parallelizable with:** none
+```bash
+dotnet build ZB.MOM.WW.OtOpcUa.slnx
+dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "Category!=E2E&Category!=LiveIntegration"
+git ls-files lib/                                    # empty
+git ls-files | grep -E '^(pending|current|looseends|stillpending|HISTORIAN)'   # empty
+find src tests -maxdepth 3 -type d -iname '*Wonderware*'                        # empty
+ls .github/workflows/                                # v2-ci.yml only (of the two)
+git log --oneline -6                                 # the 4-5 hygiene commits
+```
+Expected: build + tests green, all hygiene assertions empty/clean. Update
+`archreview/plans/STATUS.md` marking R2-12 items done (U-9 was already marked).
+
+**Overall effort: Small (~half a day; +up to 2 h only if C-4 Branch A opens).**
diff --git a/archreview/plans/R2-12-repo-hygiene-plan.md.tasks.json b/archreview/plans/R2-12-repo-hygiene-plan.md.tasks.json
new file mode 100644
index 00000000..f0e7f48a
--- /dev/null
+++ b/archreview/plans/R2-12-repo-hygiene-plan.md.tasks.json
@@ -0,0 +1,19 @@
+{
+  "planPath": "archreview/plans/R2-12-repo-hygiene-plan.md",
+  "tasks": [
+    { "id": 0,  "subject": "T1: U-5 proof — zero references to the 7 lib/ DLLs (grep csproj/props/targets/slnx + no HintPath) + build", "status": "pending", "blockedBy": [] },
+    { "id": 1,  "subject": "T2: U-5 — git rm -r lib/ + .gitignore lib/ guard + rebuild (Commit 1; MAINTAINER SIGN-OFF)", "status": "pending", "blockedBy": [0] },
+    { "id": 2,  "subject": "T3a: U-6 proof — husk dirs untracked (git ls-files = 0) + absent from slnx", "status": "pending", "blockedBy": [] },
+    { "id": 3,  "subject": "T3b: U-6 — rm -rf the 5 Driver.Historian.Wonderware* husk dirs (disk-only, no commit)", "status": "pending", "blockedBy": [2] },
+    { "id": 4,  "subject": "T5a: U-4 proof — 5 planning files tracked; re-read looseends.md; present per-file table for sign-off", "status": "pending", "blockedBy": [] },
+    { "id": 5,  "subject": "T5b: U-4 — untrack pending/current + gitignore, archive stillpending + HISTORIAN-GATEWAY to docs/plans/, delete looseends.md, repoint docs/Historian.md (Commit 2; SIGN-OFF for the delete)", "status": "pending", "blockedBy": [4] },
+    { "id": 6,  "subject": "T6: S-3 — git rm .github/workflows/v2-e2e.yml (zero Category=E2E proven; SIGN-OFF); record E2ETests+zero-match-guard follow-up", "status": "pending", "blockedBy": [] },
+    { "id": 7,  "subject": "T7: P-5 — delete redundant integration matrix job from v2-ci.yml + fix header comments; YAML-validate (Commit 3 with T6)", "status": "pending", "blockedBy": [6] },
+    { "id": 8,  "subject": "T8: U-3 — add ack/confirm/shelve/enable/disable sections to docs/Client.CLI.md; correct command count to 13", "status": "pending", "blockedBy": [] },
+    { "id": 9,  "subject": "T9a: C-5 proof — ScadaBridge sweep of StyleGuide.md + confirm no .editorconfig", "status": "pending", "blockedBy": [] },
+    { "id": 10, "subject": "T9b: C-5 — StyleGuide ScadaBridge->OtOpcUa + minimal suggestion-severity .editorconfig; no new TWE warnings (Commit 4 with T8)", "status": "pending", "blockedBy": [9] },
+    { "id": 11, "subject": "T10: C-4 — probe feed for xunit-v3 Akka TestKit; Branch A migrate 3 holdouts + drop 2.9.2 pin, else Branch B bump 2.9.x + blocker comment at the pin (Commit 5)", "status": "pending", "blockedBy": [] },
+    { "id": 12, "subject": "T11: final verification — whole-solution build+test, all hygiene assertions empty, update STATUS.md (U-9 already done, do not re-plan)", "status": "pending", "blockedBy": [1, 3, 5, 7, 8, 10, 11] }
+  ],
+  "lastUpdated": "2026-07-12"
+}
diff --git a/archreview/plans/STATUS.md b/archreview/plans/STATUS.md
index b868315f..0271f9e9 100644
--- a/archreview/plans/STATUS.md
+++ b/archreview/plans/STATUS.md
@@ -1,9 +1,16 @@
 # Arch-Review Remediation — Status & Handoff
 
-**Snapshot:** 2026-07-08 · **Review baseline:** master `9cad9ed0` · this file is the single source of
-truth for remediation progress. Update it as work lands.
+**Snapshot:** 2026-07-12 · **Review baseline:** master `9cad9ed0`; **re-review baseline:** master
+`f6eaa267` · this file is the single source of truth for remediation progress. Update it as work lands.
 
-## Branch topology (all LOCAL — nothing pushed, nothing on master)
+> **2026-07-12 UPDATE — everything below the topology table landed:** all remediation branches were
+> **merged to master and pushed to `origin/master`** (merge commits `7834b3b7`…`b67bd9e8`; final state
+> `f6eaa267`). The topology table is kept for history but is no longer live. A full **re-review** of all
+> seven domains ran at `f6eaa267` — see [`../00-OVERALL.md`](../00-OVERALL.md) and the
+> "Re-review 2026-07-12" section at the bottom of this file for the new findings (one High regression
+> in the S7 fix, one High ResilienceConfig validation gap, and a batch of resilience-seam Mediums).
+
+## Branch topology (HISTORICAL — as of 2026-07-08; since merged + pushed)
 
 | Branch | Head | Contains |
 |---|---|---|
@@ -106,10 +113,40 @@ so from a critical branch read them via `git show docs/archreview-plans-and-drif
   each add new sink capability methods that ship inert unless forwarded through `DeferredAddressSpaceSink`
   (F10b/PR#423 precedent). The reflection guard now fails loudly if any such member isn't forwarded — add the
   member to the recorder-covered interface set and it's auto-checked.
-- **Nothing is pushed.** 8 local branches: the 4 `fix/archreview-crit*` (crit1 now carries #9 too), the `u2`,
-  `c1-wire-analyzer`, and `s1-s2-ci-coverage` guard branches, + the docs branch. The docs branch can merge first
-  (plans + doc fixes are low-risk).
-- **CI changes can't be run-verified without a push** — the S-1/S-2 workflow + skip-gate were validated locally
-  (YAML parse + script against real/synthetic trx), but the actual CI-job run is only observable once pushed.
-- **Two findings (#10, #11) need user decisions** before those areas are "closed" — see the Findings section above.
-- **Plans are on the docs branch only** — see topology note above for cross-branch reads.
+- ~~**Nothing is pushed.**~~ **Superseded 2026-07-12:** all branches merged to master and pushed to
+  `origin/master`; the plans now live on master too (no more cross-branch `git show` needed).
+- ~~**CI changes can't be run-verified without a push**~~ — now pushed; the first real CI-job run should be
+  checked on origin (the re-review verified the YAML statically only).
+- **Findings #10 and #11 are resolved** (see above); the open decisions now come from the 2026-07-12
+  re-review below.
+
+## Re-review 2026-07-12 (master `f6eaa267`) — outcome + new findings
+
+All seven domain reports were re-run and rewritten in place at `f6eaa267` (each carries a
+`Prior-finding status (9cad9ed0 → f6eaa267)` table; synthesis in [`../00-OVERALL.md`](../00-OVERALL.md)).
+Verdict on the remediation: **all 4 Criticals verifiably closed in code** (Crit-1 by premise correction),
+both systemic guards live with negative controls, CI now whole-solution + fail-on-skip
+(07 stability 2.5→3.5). New findings needing scheduling, ranked:
+
+| Rank | Finding | Severity | One-line |
+|---|---|---|---|
+| 1 | 05/STAB-14 | **High (regression)** | S7 ensure-wrapper rethrows connect-timeout OCE → poll loop's bare `catch (OCE) { return; }` silently kills all S7 subscriptions after an unreachable-host (non-refusing) outage; live bounce test blind to it (`S7Driver.cs:488,1000,1383,1404,1206-1208`) |
+| 2 | 01/S-6 | **High** | Operator-authorable `timeoutSeconds:0` in ResilienceConfig → Polly ValidationException on every capability call = permanent driver-brick; no range validation in parser or AdminUI form |
+| 3 | 05/STAB-15 | **High** | S7 fatal-classification misses framing/desync + cancel-during-I/O (half-read PDU → permanent desync); probe catch never marks handle dead |
+| 4 | 01/S-8 = 03/S12 | Medium | Dispatch hardcodes `isIdempotent:true`; `WriteIdempotentAttribute` has zero readers → #13-enabled write retries would retry command writes |
+| 5 | 02/S13 | Medium | VT script failure/timeout leaves node stale-Good forever (timeout fix made the failure invisible) |
+| 6 | 01/U-6 | Medium | Bulkhead knob parsed/authored/documented but never built into the pipeline — dead knob |
+| 7 | 06/S-11 | Medium | Enabled-but-misconfigured ServerHistorian crash-loops Host with raw UriFormatException (only config side fixed in 7233e2ba) |
+| 8 | 06/U-7 | Medium | HistorianGateway 0.2.0 proto3-optional DataType change silently alters GatewayTagProvisioner Boolean provisioning; live EnsureTags never run on 0.2.0 |
+| 9 | 04/C-7, 04/U-5=03/U10, 01/S-7=03/S13 | Medium/Low | Resilience seam edges: lossy AdminUI form round-trip; status tracker has no reader (operators blind); respawn stale-pipeline cache race |
+
+Carried-open Highs unchanged from round 1 (no branch targeted them): failure-visibility trio
+(01/S-1, 06/S-1, 03/S4), AdminUI authz (04/C-1 — stakes rose: resilience config is on the ungated
+surface), 03/S2/S3 block-bridging, 03/P1 surgical adds (guard prerequisite in place), driver-fleet batch
+(05), repo hygiene (07). Full prioritized list in `00-OVERALL.md`.
+
+**Round-2 plans WRITTEN (2026-07-12):** all 12 action items have design+implementation plans —
+`R2-01`…`R2-12` in this directory (+ co-located `.tasks.json` each, for resumable execution). Index,
+per-plan task counts, effort, and suggested execution order: [`00-INDEX.md`](00-INDEX.md) §"Round 2
+plans". 193 bite-sized TDD tasks total, ~18–24 dev-days end-to-end; R2-01/02/03 (the re-review's
+new-and-sharp items) are ~2 days combined.