Inject the custom UnwrappedCapabilityCallAnalyzer as an OutputItemType=Analyzer
ProjectReference from Directory.Build.props (excluding the analyzer + its test
project) so OTOPCUA0001 runs on every src/ and tests/ compilation — it previously
enforced its CapabilityInvoker-wrapping rule against nothing but its own 31 unit
tests (the 'built-but-never-wired' failure mode).
Triage of the ~280 surfaced hits, three categories:
1. RESILIENCE-DISPATCH-GAP (7 sites, DriverInstanceActor x6 + GenericDriverNodeManager
x1): a REAL, previously-untracked gap the analyzer caught on first wiring — the
Phase 6.1 CapabilityInvoker resilience pipeline (retry/breaker/bulkhead/telemetry)
is constructed ONLY in tests and was never wired into the production dispatch
layer. Scoped per-site #pragma with a greppable RESILIENCE-DISPATCH-GAP marker
explicitly noting these are tracked-but-not-intentional, pending the dispatch-wiring
remediation (filed as a follow-up). Keeps the analyzer live everywhere else in
those projects so a NEW unwrapped call still fails the build.
2. Driver-INTERNAL self-calls (3 sites, AbCipAlarmProjection x2 + S7Driver x1):
a driver's own poll/ack path calling its own capability method. The invoker wraps
the driver from the dispatch layer OUTWARD; a driver re-wrapping its own internal
calls would double-wrap. Genuinely intentional — scoped #pragma with that rationale.
3. Wire-level test suites + manual-testing CLIs (12 projects): invoke drivers directly
by design — the analyzer's own documented intentional case. Project-level NoWarn
with a comment.
Verified: full solution build green, 0 OTOPCUA0001 hits; analyzer's 31 tests pass;
negative control — dropping one dispatch-gap pragma re-fires OTOPCUA0001 and fails
the Runtime build, proving the analyzer is genuinely live tree-wide, not disabled.
Native ADS notifications (the default subscribe mode) were stored as opaque handles
with no record of the symbol/type/interval/handler needed to replay. On a client swap
(EnsureConnectedAsync building a fresh client after a drop) the notifications were
silently orphaned — no Bad status, no error, pushes just stopped until redeploy.
Compounding: the IsConnected fast-path keys on AMS-port state, not wire liveness, and
a probe failure only transitioned state without recycling the dead client.
Fix:
- Store REPLAYABLE INTENT: NativeRegistration (symbol/type/bit/interval/onChange +
swappable live handle) hung off DeviceState.NativeRegistrations, populated by
SubscribeAsync via RegisterNotificationAsync (under ConnectGate).
- Split EnsureConnectedAsync into a gate wrapper + EnsureConnectedUnderGateAsync core;
when the core installs a NEW client it replays every stored intent onto it and swaps
the live handle (disposing the dead one). Register + replay both run under ConnectGate
so they can't race.
- Probe loop: on a wire-probe failure (false or throw) RecycleClientAsync disposes+nulls
the client so the next tick rebuilds + replays — closes the fast-path-keys-on-port-state
compounding bug.
No TwinCAT docker fixture exists (integration needs a real TC3 XAR), so the fake-client
unit tests are the authoritative coverage:
- 4 new guards in TwinCATReconnectReplayTests (replay-onto-fresh-client + push reaches
OnDataChange + old handle disposed; replay-all-tags; unsubscribe-after-reconnect stops
replaying; probe-failure recycles+rebuilds).
- Full TwinCAT unit suite 174/174 green; full solution builds 0 errors.
The S7 Plc was opened once in InitializeAsync and never re-opened: a transient
PLC reboot / network blip permanently killed the driver until redeploy. Introduce
an IS7Plc / IS7PlcFactory seam (mirrors TwinCAT's ITwinCATClientFactory) and a lazy
EnsureConnectedAsync that disposes a dead handle and re-opens a fresh connection on
the next data call. Reads/writes mark the handle dead on a connection-fatal fault
(socket drop / ErrorCode.ConnectionError) but NOT on a data-address error; the probe
loop routes through EnsureConnectedAsync as a backstop.
The seam also closes the S7 TEST-1 gap — the reconnect state machine is now unit-
testable without a live PLC (S7.Net.Plc is sealed with no in-process fake).
Verification:
- 4 deterministic unit guards (fatal→reopen, data-error→no-reopen, raw socket→reopen,
reopen-fail→degrade-then-recover); full S7 unit suite 234/234 green.
- LIVE end-to-end proof against real python-snap7 (S7_1500ReconnectTests, double-gated
on sim reachability + S7_RECONNECT_BOUNCE_CMD): bounced the container, driver observed
the outage and resumed Good reads with NO redeploy. Baseline smoke 3/3 still green.
The gateway now populates Runtime.dbo.Events.Source_Object from the event
SourceName (gateway C4 fix: HistorianProtoMapper threads SourceName into the
`source_object` event property). So an ad-hoc alarm SendEvent is now source-
filterable on readback, and Alarm_SendEvent_then_ReadEvents asserts the round-
trip instead of skipping with the old "ad-hoc sends land without Source_Object"
reason. Poll window widened to 60s for the live event-view flush latency.
Live-proven at the gateway level (HistorianGateway EventSourceObjectProbeTests,
2026-06-27); this asserts the same round-trip through the OtOpcUa alarm writer +
data source adapters.
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
Live investigation showed the earlier 'C2 server-gated event reads' attribution was
wrong: the gateway's SQL event reader works (a source-filtered ReadEvents returns a
real Galaxy-sourced event's history; a time-only ReadEvents returns 50 events). The
alarm round-trip's source-filtered readback is empty only because an ad-hoc SendEvent
is recorded in Runtime.dbo.Events WITHOUT a Source_Object — so reading existing Galaxy
alarm/event history by source works, but round-tripping OtOpcUa's own sends by source
needs the gateway's SendEvent to populate the event source. Skip message corrected.
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
Live validation against wonder-sql-vd03 caught that the gateway's SendEvent handler
throws when the wire event carries a client-supplied Id — so every alarm send from
OtOpcUa failed (PermanentFail). AlarmEventMapper now leaves HistorianEvent.Id unset
(the historian assigns event identity) and preserves the alarm's id as an 'AlarmId'
property. With this, the live alarm send acks.
Also harden the env-gated live tests against two gateway/historian-side limitations
surfaced during validation (neither an OtOpcUa defect): the write readback uses a
timezone-tolerant window (an explicit-timestamp WriteLiveValues lands offset by the
deployment's local-vs-UTC delta — reproducible via raw grpcurl; OtOpcUa sends correct
UTC), and the alarm ReadEvents readback skips with a clear reason when the historian's
server-gated event reads (C2, won't-fix) return nothing. Read + write-persist +
alarm-send are all live-validated green; the alarm send-ack is split into its own test.
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii