Regression check first, since this changes ACK semantics: a normal deploy still
records Applied on all six nodes. Then the same induction as #485 — the node
now records Failed with a reason while the five nodes that read the real
artifact record Applied (pre-fix all six claimed Applied), and the log shows it
staying on the PREVIOUS revision rather than the dispatched one, which is what
lets a retry land. Values kept flowing throughout (59603, current), and the
next real deploy applied normally.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
All three guards observed firing in production, on two nodes, with the live
values proving the subscription half rather than just the log line: the Modbus
tag advanced 56278 -> 56336 -> 56354 straight across the induction.
The RED control was not staged. site-a-1 had hit #485 during the PREVIOUS
gate's SQL flapping and had been serving an empty address space for 34 minutes
(PureRemove, removed=17) on the pre-fix image — the bug in its natural habitat.
Rebuilding on the fixed image restored it, and the induction then failed to
reproduce the teardown.
The original trigger was a race, so the gate drives the empty-bytes branch
instead — byte-for-byte what the code sees when the row cannot be read, and the
branch the driver-side guards key on (their throw paths already returned early
before this work). Pausing the target node opens the window deterministically;
both runs measured <1s, well inside acceptable-heartbeat-pause, so no failover
was triggered. Of the six nodes exactly one took the guard path — the other
five read the real artifact and applied it, which is its own control.
Records one pre-existing defect this work does NOT fix: ApplyAndAck advances
_currentRevision and writes NodeDeploymentState=Applied before knowing whether
anything applied, so a node that skipped reports success for a revision it
never applied — and since dispatch short-circuits on a revision match it cannot
self-heal from a re-dispatch. Data plane is unaffected (the node keeps serving
its last good config); it is a reporting/convergence bug. Left out of these
fixes because correcting it changes deploy ACK semantics fleet-wide.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The driver-side half of the same mistake the address-space fix corrected.
ReconcileDrivers and PushDesiredSubscriptionsFromArtifact both read the
artifact as `...FirstOrDefault() ?? Array.Empty<byte>()`. Their THROW paths
already returned early, but empty bytes flowed onwards as a real answer: zero
driver specs, so DriverSpawnPlanner planned every running child for StopChild,
and then an empty desired set dropped each surviving driver's live
subscription handle. A node lost its entire field I/O and still ACKed Applied.
Same rule as the address space: no bytes is no answer, not "a configuration
with no drivers". Nothing legitimate produces a zero-length blob — deleting
the last driver still deploys a JSON document with empty arrays. Both sites
now skip and keep what is running. The two guards are independent because
PushDesiredSubscriptions does its OWN ConfigDb read, so the row can go missing
between them.
Also corrects the ReconcileDrivers doc comment, which claimed an empty blob
made it "effectively a no-op" — with children running it was the opposite.
Tests: DriverHostActorUnreadableArtifactTests, RED-first (verified failing —
after the empty dispatch the driver list was empty). A zero-length ArtifactBlob
reproduces byte-for-byte what the missing-row case delivers, so the race does
not have to be constructed.
Two controls, both load-bearing:
- a READABLE driverless artifact still stops the driver, so the guard keys on
"the read gave us nothing", not on "fewer drivers than before";
- dropping a driver's LAST TAG still clears its subscription. This one was
added after the absence assertion was caught passing on a race: the
unsubscribe is an async self-tell, so with the second guard deleted the test
still went green. The control observes that same unsubscribe arriving well
inside the settle window, which is what makes the absence meaningful — with
the guard deleted the suite now correctly goes RED.
SubscribableStubDriver gains an UnsubscribeCount for that observation.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
A transient ConfigDb error while loading a deployment's artifact emptied the
served address space. LoadArtifact caught the exception, logged "rebuild
becomes no-op" and returned zero bytes; those parsed to an EMPTY composition,
which the planner diffed against the live one as a PureRemove and the applier
faithfully executed — 16 nodes gone, while the log claimed nothing happened.
An artifact we could not obtain is not an empty configuration. Nothing
legitimate produces a zero-length blob (an operator who really deletes
everything still deploys a JSON document with empty arrays), so "no bytes" can
only mean "no answer". HandleRebuild now abandons the rebuild on one, leaving
both the materialised nodes and _lastApplied intact so the retry diffs against
what is actually being served. The loader's own log line is now true.
The two cases are logged differently: warn when there is a live address space
being protected, debug when nothing has been deployed to this node yet.
Covered by a RED-first actor test (verified failing: the load error tore down
eq-2's subtree), plus a positive control proving a READABLE artifact that
genuinely drops an equipment still removes it — the guard suppresses teardown
only when the read failed.
Found on the LocalDb Phase 1 follow-up live gate, which induced it by flapping
SQL; that gate's log is the production evidence for the seam.
Closes#485
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The gate closed both follow-ups on the docker-dev rig and found a third defect
that offline tests could not have found first: it lived behind the one being
fixed. Once 0.1.2 made back-fill work, a rebuilt node could be observed writing
for the first time — and its writes went nowhere, because the healthy node kept
the old peer's seq watermark. Library 0.1.1 -> 0.1.2 -> 0.1.3 over the course
of the gate; both consumers now pinned to 0.1.3.
Check 4 (was PARTIAL, now PASS): with SQL stopped and site-a-2's LocalDb volume
destroyed, a-1 logged "Snapshot sent (as_of_seq 0, 4 rows)" — a line that could
not appear on 0.1.1 — and a-2 came back with a row_version dump byte-identical
to a-1's, carrying a-1's origin node ids and its ORIGINAL applied_at_utc. It
then booted from cache and served 17 ns=2 nodes, diff-identical to its peer,
from a configuration it never applied and could not have fetched.
Check 8 (PASS): two restarts with an unchanged artifact left the oplog at 7 and
the pointer timestamp frozen — the re-cache was skipped outright. Positive
control: a real config change still wrote, 7 -> 10 -> 13.
Also records one unrelated pre-existing finding the gate's SQL flapping
surfaced: a transient ConfigDb error during artifact load empties the served
address space while logging "rebuild becomes no-op". The cache layer correctly
refused to store the bad artifact and the node recovered on restart, but the
two logs disagree and it is the same class as the Phase 1 gate's check-3
defect. Untouched by this work; wants its own issue.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
1. Wiped-node back-fill (live gate check 4). Fixed upstream in
ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly
off: the oplog cap was not the gate. Snapshot detection measured the peer's
gap against the oldest surviving oplog row and read an empty oplog as "no
gap possible" — and an empty oplog is the steady state of a converged pair,
since ack-pruning deletes everything the peer confirmed. The healthy state
was the one state that could not heal a wiped peer. Now measured against
last_acked_seq when the oplog is empty.
Pinned at this level too, not just the library's: the pair harness grows a
WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node
id and a zero watermark), and the new scenario asserts the emptied oplog as
its precondition before wiping, then requires the deployment artifact to
come back byte-identical with no new deploy. Verified RED against the pinned
0.1.1 (times out waiting for back-fill) and green on 0.1.2.
2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source:
StoreAsync now skips entirely when the pointer already names this
deployment/revision, the SHA matches the bytes, and the expected chunk count
is present. Re-caching an artifact the node already holds — every restart's
boot-from-cache, every RestoreApplied — writes nothing and mints no oplog
rows, where before it cost a delete plus an insert per chunk plus a pointer
update, all identical to what was already there.
Identity is over the bytes and the chunks are counted, both deliberately: a
re-composed artifact can carry the same ids with different bytes, and a
pointer can name a deployment whose chunks are missing, where skipping would
make an unreadable cache permanent. Both guards verified RED against a naive
pointer-only skip.
The gate's stated bound was also wrong and is corrected in the doc: growth
was never headed for the 1M row cap. AddZbLocalDbReplication is registered
unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes
and the 7-day MaxOplogAge cap prunes.
Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and
unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a
clean master too) and a flaky Roslyn race test (green 2/2 in isolation).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Records the docker-dev live gate: 8/8 checks pass (check 4 with a documented
limitation). The gate caught four real defects every offline test missed — the
NU1101 packageSourceMapping gap, the ASPNETCORE_HTTP_PORTS re-bind, the empty
address space on boot-from-cache, and the cache not repopulating on
RestoreApplied — all fixed on-branch with regression tests. Two limitations
documented as follow-ups: no replication back-fill of a fully-wiped node, and
oplog growth on default-OFF nodes.
Offline sweep: full-solution build 0 errors (824 pre-existing OTOPCUA0001
analyzer warnings live in driver test projects, none in LocalDb files);
no LiteDB/LocalCache code references remain (2 explanatory doc-comment lines
allowed); Runtime.Tests 407/0/31 twice (flagged intermittent not reproduced);
Host.IntegrationTests LocalDb subset 40/0/0. Full-solution test suite with
stash-baseline deferred to CI/live gate — infra-gated suites cannot run offline
on macOS. Not merged to master; live gate (Task 16) awaits operator go-ahead.
New operations runbook (enable/disable, fail-closed ApiKey rule, stop/start-
together, tombstone-retention window, MaxBatchSize row-count-vs-4MB, never
sqlite3-a-live-WAL-DB cp-triplet recipe). Configuration.md gains a LocalDb
section; Redundancy.md gains a pair-local config cache section (what boot-from-
cache does and does not cover); CLAUDE.md gains the Phase 1 scope paragraph.
Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs
invites the next reader to wire the wrong one.
Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's
list and would have failed to compile (it calls new LiteDB.LiteDatabase directly).
StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too.
The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed:
it now records that no sign-in fallback exists and that reviving one means an admin-side
cache on LocalDb.
Post-implementation review (HIGH finding) noted #478's mux-delivered
input-quality path does not cover a driver comms-loss: a poll driver
(Modbus/S7) whose device goes unreachable emits only ConnectivityChanged and
goes silent on the value feed, so a scripted alarm keeps the last Good value.
The code as shipped faithfully implements #478's written scope (worst of input
tags' qualities via the dependency mux). The comms-loss bridge for scripted
alarms (symmetric of native #477-L2, plus the null-value/cold-start asymmetry
and its VT-quality ripple) is tracked as #481. Docs updated in
AlarmTracking.md + the design doc.
Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST
quality across its input tags, mirroring the native OT semantic (#477 L2).
Plumbing (quality was silently discarded twice on the live path):
- VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the
DependencyMuxActor forwards the published AttributeValuePublished.Quality it
already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real
quality into the engine (was hardcoded 0u/Good).
Engine (Core.ScriptedAlarms):
- ScriptedAlarmEngine computes worst-of-input quality each eval (skipping
not-yet-published inputs, which are a readiness concern, not a quality signal)
and carries it on ScriptedAlarmEvent.WorstInputStatusCode.
- A real transition carries the current worst quality so ToSnapshot's full
snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain).
- A Bad input freezes the condition (no transition), like a comms-lost native
driver; a quality-bucket change with no transition emits the new
EmissionKind.QualityChanged, routed to the existing #477-L2
AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts
row, no historian write). ScriptedAlarmSource skips QualityChanged so it never
fabricates a phantom IAlarmSource event.
Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission
routes QualityChanged out of band.
Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged +
unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality;
host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst.
Docs: AlarmTracking.md Layer-3 section + design doc.
Closes#478
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".
Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
member so a quality-bucket change fires a Part 9 event.
Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
fires only on a bucket change — never touches Active/Acked/Retain (an active
alarm that loses comms stays active). Not a full-snapshot re-projection, so it
can't clobber severity/message and works for a never-fired condition.
Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
reflection forwarding guard). Ungated by redundancy role; no /alerts row.
Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).
Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.
Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType
classification fields, so every condition event — native and scripted — shipped
ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same
mechanism as #473: Create() builds the mandatory children from the type's
embedded definition but leaves them unset, and nothing downstream synthesises
them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI
bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified.
Report BaseConditionClassType — Part 9's "no condition class modelled" value.
This is the honest report: we hold no classification at the materialise seam.
Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would
assert a classification we cannot back and would be actively wrong for a Galaxy
alarm whose upstream category is Safety/Diagnostics — trading a detectable null
for an undetectable lie. Real per-alarm classification needs the driver's
AlarmCategory carried to this deploy-time seam (it lives only on the runtime
AlarmEventArgs transition today) and is a separate feature.
Guards, both observed RED against the pre-fix server:
- NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause
(the #473 test's clause mirrors ScadaBridge's exactly and its indices are
load-bearing, so it is left untouched). The class fields are declared on
ConditionType, not BaseEventType, so they are selected against that type.
- NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns).
Stacked on #473 (PR #474) — merge after it.
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.
EventType = the concrete materialised type (TypeDefinitionId)
SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
the source; an alarm-bearing raw tag materialises only the
condition, with no sibling value variable
SourceName = the same identifying id string: RawPath (native) /
ScriptedAlarmId (scripted)
SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).
Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.
The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
OtOpcUa is Akka-clustered and AddZbSecrets is registered unconditionally, so every
node (admin, driver, fused) resolves secrets and needs the SAME KEK + same store rows.
Ship G-5 as a production-posture runbook rather than hardcoding Source=File + shared
paths into the committed role-overlay appsettings (which dev + TwoNodeClusterHarness
also consume — that would break every dev/CI boot). Base appsettings stays on
Source=Environment. Documents the interim File-KEK + shared-SQLite posture and the
G-7 hand-off (ConfigDb-backed ISecretStore mirroring the existing DP
PersistKeysToDbContext<OtOpcUaConfigDbContext> key-ring sharing). Cross-linked from
docs/security.md. Mirrors the ScadaBridge G-5 resolution.
OtOpcUa commits no plaintext secrets — the only secret-shaped committed value is
ServerHistorian:ApiKey='' (empty, disabled by default). Because the pre-host expander
is fail-closed AND section-agnostic, hard-committing a ${secret:} token would break
every dev/CI/integration boot for zero at-rest benefit. Instead document the token
delivery convention for all five config secrets (jwt/ldap/deploy/configdb/historian)
in docs/security.md and note the token option in the ServerHistorian comments.
Mechanism proven live: unseeded token boot fails with SecretNotFoundException at
pre-host expansion; seeded token resolves and boot proceeds. Behavior-neutral
(comment + doc only).
Uns.md Tags section rewritten to the reference-only model (UnsTagReference list,
cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md
gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Live-verified on docker-dev: AbCip picker renders Browse button (full CanBrowse chain), clicking
it runs the universal-browser capture in production (central-2 logs) with PatchForBrowse
demonstrably applied (@tags walk ran without the authored config setting EnableControllerBrowse);
error surfaces gracefully. Modbus picker shows NO Browse button (negative). Populated tree-render
is fixture-blocked — ab_server returns ErrorUnsupported for the controller @tags walk (test sim
limitation, not a code defect). Two follow-ups recorded: (1) full tree-render needs a symbol-browse
capable AB backend; (2) driver-page pickers don't pass live config to the picker body.
Task 14/15/16: each picker body keeps its manual builder and appends a DriverOperator-gated
browse affordance (mirrors OpcUaClientAddressPickerBody) — Browse/Refresh/Close, DriverBrowseTree
over the universal DiscoveryDriverBrowser session, leaf-only commit (OnNodeSelected fires for
folders too), snapshot-at-open label, fire-and-forget close on dispose. _canBrowse evaluated once
in OnInitializedAsync (CanBrowse constructs a throwaway driver). FOCAS commits the leaf FullName
directly (the group/id builder can't reconstruct it). Editors pass DriverType + GetConfigJson
through. Razor-live-verified at Task 18.
Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
the node set is non-empty and stable across two passes, bounded by open-timeout; on
timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver
(their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled
which PatchForBrowse guarantees at open). One gate fact per driver test project.
Every concern the 7-agent review parked is now decided and integrated:
- universal browser: in-flight capture coalescing keyed (driverType,
config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
throwing TryCreate and defensively shuts down the throwaway instance;
cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
BadCommunicationError stays reserved for the driver's own transport
failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
bounds only the stream-start handshake; library version/TFM folded into
the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
ManagedMqttClient); Wave-2-start library re-verification checkbox;
implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
refs on admin nodes (actionable error + session-only pasted literal);
one-row-per-key query contract (last-wins + rate-limited warning);
absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
is the first P1 live-gate item; AbCip harness static-init anti-pattern
note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
(the universal browser's CanBrowse throwaway instances depend on it).
Per user decision 2026-07-15: the direct-serial transport (ModbusRtuTransport,
System.IO.Ports, serial config fields, socat live rig) is not being built.
Serial RS-485 buses are reached exclusively via serial->Ethernet gateways
(Moxa NPort etc.) using the new ModbusRtuOverTcpTransport — zero new package
deps, no container device mapping, reuses the hardened socket lifecycle.
ModbusTransportMode shrinks to Tcp|RtuOverTcp (Rtu member reserved). The
direct-serial design is kept in the RTU doc's §2b as a marked record,
including the R2-01 SerialPort-BaseStream-ignores-ReadTimeout trap.
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.
All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:
- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
+ FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
#420 (filed 2026-05-01 as '4 inert scaffolds + D.1 smoke') is stale. Current
master + the mxaccessgw sibling repo show the feature landed on both sides:
- mxaccessgw A.2/A.4: native alarms served via the wnwrap consumer
(WnWrapAlarmConsumer over WNWRAPCONSUMERLib.wwAlarmConsumerClass) +
FailoverAlarmConsumer + AlarmDispatcher; StreamAlarms active-alarm snapshot
(20 alarms verified live). The WM_APP-pump premise this issue was written
against was ruled out by the 2026-05-01 probes; the design pivoted to wnwrap.
- mxaccessgw A.3: NotWiredAlarmRpcDispatcher gone (only in generated proto);
ack routes MxGatewayClient.AcknowledgeAlarmAsync -> AlarmClient.AlarmAckByGUID.
- lmxopcua: GalaxyDriver : IAlarmSource wires GatewayGalaxyAlarmFeed
(-> OnAlarmEvent, native metadata incl. operator-comment) + inbound ack via
GatewayGalaxyAlarmAcknowledger; native feed live-proven by
GatewayGalaxyAlarmFeedLiveTests.
- C.1 (SdkAlarmHistorianWriteBackend / aahClientManaged): SUPERSEDED. The whole
Wonderware sidecar was retired; alarm-history writes cut over to the
HistorianGateway (GatewayAlarmHistorianWriter, SendEvent path), live-verified
2026-07-15 (R2-06/R2-08, 6/6). The target no longer exists in the tree.
Updated the alarms-over-gateway.md banner (final reconciliation) + the
alarms-d1-smoke-artifact.md status note. Sole residual = a Windows-parity-rig
running-server OPC UA A&C round-trip (needs live Galaxy + human IDE).
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.
Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.
Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
process exits (supervisor restarts it) instead of idling with a dead actor system.
Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
ANY single loss needs 3+ members.
Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
The historian's Int1 analog-tag creation path is server-degenerate:
EnsureTags(Int1) stores an unusable stub (live-probed on the deployed
2023 R2 historian; root-caused + documented gateway-side in
HistorianGateway #11 / PR #16). HistorianTypeMapper mapped
DriverDataType.Boolean -> HistorianDataType.Int1, so Boolean historized
tags failed auto-provisioning.
Map Boolean -> Int2 instead (store 0/1). Int2 is a proven-creatable
analog type, and the value-write path already sends every value as a
double on WriteLiveValues regardless of tag type, so Boolean 0.0/1.0
round-trips cleanly. Both Float and Int2 are supported, so a
pre-existing Float Boolean tag retypes cleanly on its next deploy.
- HistorianTypeMapper: Boolean => Int2 (+ remarks)
- Unit pins: HistorianTypeMapperTests, GatewayTagProvisionerTests
(Boolean_definition_carries_explicit_Int2_presence)
- Live gate: EnsureTags_boolean_sandbox_provisions_as_Int2 (was _Int1);
retype probe reframed Float->Int2 (drops the dead Int1 leg)
- Docs: docs/Historian.md 'Boolean historization maps to Int2', CLAUDE.md,
archreview STATUS.md (R2-06 live gate -> 6/6)
Resolves#441.
- StorageRateMs bug found+fixed (PR #439); live suite 3/6 -> 5/6
- U-7: EnsureTags is a real upsert; supported-type retype ACCEPTED (Float->Double);
Int1 not creatable on this histsdk (ProtocolEvidenceMissingException, Int1-only)
- record the observed retype policy in docs/Historian.md (fills the placeholder)