144 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repository is
scadaproj is primarily an umbrella/index workspace that aggregates a family of
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as sibling
directories under ~/Desktop/. It now also hosts nine shared libraries itself (verified
2026-07-18) — ZB.MOM.WW.Auth/ (login/identity),
ZB.MOM.WW.Theme/ (UI kit),
ZB.MOM.WW.Health/ (health checks),
ZB.MOM.WW.Telemetry/ (observability),
ZB.MOM.WW.Configuration/ (config validation),
ZB.MOM.WW.Audit/ (audit event model),
ZB.MOM.WW.Secrets/ (encrypted secret store),
ZB.MOM.WW.GalaxyRepository/ (Galaxy browse), and
ZB.MOM.WW.LocalDb/ (embedded cache + 2-node sync) — all the realized output of their
respective component normalizations (see Component normalization).
(A tenth directory, ZB.MOM.WW.SPHistorianClient/, is retired — 0 tracked files, only
gitignored build residue left on disk.)
The point of this file is to give a high-level scan of each sister project — its purpose,
location, stack, and primary commands — so a fresh Claude Code session can orient across
the whole family without opening each repo first.
Each sister project keeps its own authoritative CLAUDE.md. This index is a
summary of those files; when you actually work in a project, open that project's
own CLAUDE.md for the full picture. See Refreshing this index.
The project list below is curated manually. Add or remove entries as the family changes — do not assume every directory under
~/Desktop/belongs here.
Sister projects (core SCADA/OT family)
Runtime / implementation (active code)
| Project | Location | Stack | Repo | Summary |
|---|---|---|---|---|
| OtOpcUa | ~/Desktop/OtOpcUa |
.NET 10, OPC UA, gRPC | gitea.dohertylan.com/dohertj2/lmxopcua |
OPC UA server that exposes industrial data sources under a two-subtree (dual-namespace) address space (v3.0, merged to master 2026-07-16, PR #472, merge ec6598ce) — native-protocol drivers (Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient), a read-only Sql DB-poll driver (polls DB tables/views on an interval → OPC UA variables; connectionStringRef env resolution, schema-walk address picker, live-gated on docker-dev — merged to master 2026-07-24. Three providers ship as of 2026-08-05 (merge d54f3c83): SQL Server, PostgreSQL (managed Npgsql) and Oracle (managed Oracle.ManagedDataAccess.Core) — no native/thin-client installs; MySql/Odbc remain reserved enum names that fail at construction. Each dialect owns its own parameter markers/bind names + ConfigureCommand (Oracle sets BindByName, and its binds carry a p_ prefix because Oracle rejects a bind named after a reserved word — ORA-01745). Live-gated against dedicated fixtures on 10.100.0.35 (postgres :15432, oracle-free :11521): env-gated integration suites 11/11, plus a chrome/AdminUI gate on docker-dev (provider dropdown authoring, Test-connect, /raw browse + browse-commit) that found and fixed a SqlServer-only gate left in the Test-Connect probe, merge 4e461951), an Mqtt broker-subscribe driver with Sparkplug B (one MQTTnet-5 client per driver, TLS+auth, hand-rolled reconnect; Plain mode binds a tag to a concrete topic, SparkplugB mode decodes vendored Eclipse-Tahu protobuf under one spBv1.0/{groupId}/# subscription and binds by the group/edgeNode/device?/metric tuple — birth/alias/seq-gap/rebirth state machine, death→STALE, a birth-driven browse picker with a scoped Request-rebirth NCMD; live-gated on docker-dev against a Mosquitto TLS+auth broker + a project-owned C# Sparkplug edge-node simulator on 10.100.0.35:8883 — merged to master 2026-07-27, merge c3a2b0f7, pushed to origin; three follow-ups since, all live-gated and merged to master: (1) write-through is no longer deferred (Gitea #508, merge 2a7d1167, 2026-07-31, 21/21 live) — MQTT nodes are writable where the tag declares a destination: a Plain tag publishes to a new writeTopic TagConfig key, a Sparkplug tag encodes an NCMD/DCMD command. Consumer-visible to any OPC UA client, i.e. ScadaBridge: an MQTT node now materializes SecurityClassification.Operate instead of ViewOnly when its tag declares a write destination, and BadNotWritable when it does not — browse-time writability is computed by the same encoder the write path uses, so a node can never advertise writability it would then refuse. Good means published, not applied: MQTT has no application-level ack, so the write posture is optimistic-Good with the standard write-outcome self-correction behind it. (2) Sparkplug primary-host STATE publishing (Gitea #511, merge 857a10b4, 2026-07-31, 5/5 live) — actAsPrimaryHost was accepted by config and did nothing; it now publishes a retained ONLINE on spBv1.0/STATE/{hostId}, arms the matching OFFLINE as the MQTT Last Will, and publishes an explicit OFFLINE on clean shutdown. The claim is bound to its own dedicated broker connection because a Will can only be registered in the CONNECT packet, and it is Primary-gated — only the node holding the redundancy Primary role claims, since an ungated pair would let a Secondary's death publish OFFLINE over a healthy Primary and stop every edge node in the group. New driver-facing seam IDriverPrimaryRoleSource in Core.Abstractions, published by DriverHostActor off the data-plane gate verdict. Accepted trade: a brief edge-visible OFFLINE window during failover. Not an OPC UA wire-contract change — this is what OtOpcUa asserts to other applications on the MQTT broker. (3) structured Sparkplug metrics (Gitea #515, merge 857a10b4, 3/3 live) — Template (UDT) and DataSet metrics were skipped with a warning; they now flatten to sibling tags (Motor → Motor/Speed, Motor/Stator/Temp; Recipe → Recipe/Step/0, Recipe/Target/1, row index always present so a growing table never renames existing tags), and a metric's PropertySet projects to node metadata, never tags. Flattening was chosen over OPC UA Structures precisely so the result is consumable by any client with no type dictionary — relevant to ScadaBridge: a Sparkplug UDT now surfaces as ordinary scalar nodes rather than being invisible. Skip-and-warn survives for everything still unflattenable (template definitions, extension values, malformed datasets, payloads past the depth/member bounds), and refusal is whole — never a partially-flattened UDT), a read-only MTConnect Agent driver (one hand-rolled System.Xml.Linq HTTP/XML client per driver against a vendor-neutral MTConnect Agent — /probe builds the device model, /current primes, and a multipart/x-mixed-replace /sample long-poll pump feeds ISubscribable.OnDataChange with ring-buffer sequence-gap re-baselining and a heartbeat watchdog; UNAVAILABLE → BadNoCommunication; the TrakHound MTConnect.NET dependency was evaluated and dropped — neither pinned package can parse an MTConnect document or frame the sample stream socket-free; browse comes free from the Wave-0 universal discovery browser, so the driver ships no browser project; write-back is deferred, every MTConnect node is read-only; live-gated on an isolated docker-dev stack against a real mtconnect/agent:2.7.0.12 + SHDR-adapter fixture on 10.100.0.35:5000 — merged to master 2026-07-27, PR #506, merge 90bdaa44, pushed to origin), and AVEVA System Platform (Wonderware) Galaxy, now a standard Equipment-kind driver (the old SystemPlatform mirror / alias-tag model was retired ~2026-06-12). v3.0 NodeId/namespace scheme (this is the wire contract ScadaBridge binds against — a CUTOVER for its Data-Connection-Layer bindings; ScadaBridge is already namespace-URI-durable via OpcUaNodeReference and its #14 phase 2 shipped 2026-07-23 (main @ 266f001a): OpcUaReferenceForm.IsDurable + a browse-picker nudge to the durable nsu=<uri>;s=… form + doc sweep. ScadaBridge #14 is CLOSED (2026-07-24) — ScadaBridge consumes v3 end-to-end. The live gates that were deferred to a real v3 rig all ran against the local otopcua-dev v3 cluster and PASSED: phase-2 nsu= hardening (266f001a), a DCL host-rewrite fix (86d129de), and the raw-path + native-alarm live gates (items B/C). Item C's premise was obsoleted by OtOpcUa #17; the item-A binding re-author (legacy ns=2 collides with v3 raw) is downstream data-only work, not a code gap. Closing comment #issuecomment-5223. Scope record: ScadaBridge docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md): the single https://zb.com/otopcua/ns namespace is replaced by two — https://zb.com/otopcua/raw (device tree Folder→Driver→Device→TagGroup→Tag, NodeId s=<RawPath>) and https://zb.com/otopcua/uns (equipment tree Area→Line→Equipment→signal, NodeId s=<Area>/<Line>/<Equipment>/<EffectiveName>); every value has ONE source (the raw tag) fanned to both NodeIds (identical value/quality/timestamp), each UNS variable Organizes-references its raw node, writes route through either NodeId, HistoryRead works via both under one historian tagname, and native alarms fan (one ReportEvent) to the raw device folder + every referencing equipment folder. The old EquipmentNodeIds ({equipmentId}/{folderPath}/{name}) scheme is retired. (v3 program = Batches 1–4: greenfield Raw-tree schema + /raw authoring UI + Calculation driver + UNS reference-only Equipment + {{equip}} reference-relative scripts, all merged; Batch 4 lit up the address space.) Galaxy access flows through the in-process GalaxyDriver → gRPC → the mxaccessgw gateway. Surfaces live read + authorized write, native OPC UA Part 9 alarms, and server-side HistoryRead. Address-space availability guarantee (matters to any OPC UA consumer, i.e. ScadaBridge — issues #485/#486, merged master 2026-07-21, live-gated on docker-dev): a deployment artifact the node cannot read is treated as no answer, never as an empty configuration. Previously a transient ConfigDb error mid-deploy emptied the served address space (a PureRemove of every node — observed live, and it stayed empty), stopped every driver and cleared every subscription, while still reporting the deploy Applied; a consumer would have seen all bindings go bad. The node now holds its last-known-good address space, drivers and subscriptions, and reports the deploy Failed without advancing its revision so the retry lands. Historian backend (merged 2026-06-27, PR #423): OtOpcUa's sole historian read/write backend is HistorianGateway (via ZB.MOM.WW.HistorianGateway.Client — continuous historization + alarms), replacing the retired bespoke Wonderware historian driver. Redundancy corrections (2026-07-21, master @ 0de1352e) — both consumer-visible: (1) the downing strategy moved from SBR keep-oldest to Akka AutoDowning (Cluster:SplitBrainResolverStrategy, default auto-down, 15s window), because a two-node pair on keep-oldest + down-if-alone could not survive a crash of the oldest node — KeepOldest.OldestDecision only rescues a side holding >=2 members, so the 1-vs-1 survivor downs itself and exits. Same fix and same rationale as ScadaBridge cf3bd52f; accepted trade is dual-active during a real partition. Its live gate is now CLOSED — Phase 7 drill D4 (2026-07-24, c50ebcf7) ran the 1-vs-1 crash-the-oldest drill on the three-mesh rig and every survivor stayed Up without self-downing. (2) The redundancy Primary is now the oldest Up driver member, not RoleLeader (lowest address) — the two diverge after any node restart, so the Primary-gated data plane could enable on a node not hosting the cluster singletons; this is the node whose ServiceLevel reads 250 vs 240, so a consumer selecting by ServiceLevel may now prefer a different node than before after a restart. NodeRedundancyState.IsRoleLeaderForDriver renamed IsDriverPrimary. The election is still scoped per Akka cluster, not per application Cluster — the per-cluster mesh design (docs/plans/2026-07-21-per-cluster-mesh-design.md) closes that; PROGRAM COMPLETE — Phases 0a/0b/1/2/3/4/5/6/7 all done + merged to master and pushed (Phase 7 c50ebcf7, bootstrap guard d1dac87f, 2026-07-24; Phase 6 was 2839deb5, Phase 5 35552a96). Phase 2 (merge 6eaa81ca) added a second command transport behind the MeshTransport:Mode dark switch (Dps default), the prerequisite for splitting the fleet into one mesh per application Cluster (central and node no longer need shared gossip membership) — originally an Akka ClusterClient. That transport has since been migrated off Akka entirely (ClusterClient→gRPC migration, 2026-08-06): Phases 0/1A/1B/2 are MERGED to master and pushed (merges 10b6d209/b360239e/7203d135; Phase 2 cutover caf2eec6 flipped the docker-dev rig default to Grpc, live gate PASS 8/8 + 135-min soak clean, full suite green on the cutover SHA) — cross-mesh commands now ride gRPC: central→node NodeCommandService on the :4056 telemetry listener, node→central deploy acks CentralCommandService/ReportApply on central's :4055, per-cluster PSK bearer auth, fail-closed, fire-and-forget with the deploy-deadline + boot-replay backstops unchanged. Phase 3 (full ClusterClient deletion — code, the HOCON receptionist/client block, CentralContactPoints, the ClusterClient mode value; SplitTopologyTransportValidator now requires Grpc) is MERGED to master @ 2fa880b6 and pushed (2026-08-06; branch carried 7 deletion steps + reviews + a docs sweep; DoD full suite exit 0, 50/50 suites, 9,326 passed / 0 failed). Phase 4 (terminal live gate) PASSED 8/8 the same day — the ClusterClient→gRPC program is CLOSED (gate record d0ac6fc5, run on master @ 0b194003, provenance-guarded post-deletion image): zero cross-mesh Akka associations (exactly 3 established :4053 connections rig-wide, all pair-internal), zero receptionist/ClusterClient log lines across every drill, PSK negatives fail-closed both services incl. unset-key boot refusal, exactly-once ack matrix, central-kill/site-kill/mid-deploy-coordinator-kill drills all honest (one loud WRN per drop, +2:00 deadlines, boot-replay, no dupes — a coordinator killed in the dispatch second had its deadline fired by the recovered coordinator on the surviving central at exactly +2:00), terminal full suite RC=0 direct capture 50/50 suites 9,333/0. Sole re-carry: the I-4 cold-PSK-cache latency measurement, structurally unmeasurable on docker-dev (ClusterPsks answers ahead of the store), deferred to the first store-backed deployment. One non-mesh observation: a same-second restart of the central pair reproduced the documented guard-off cold-start split-brain (Phase 7 Cluster:BootstrapGuard is the opt-in mitigation, default OFF) — healed by a single-node rejoin; both site pairs re-formed clean. Not an OPC UA wire-contract change — internal mesh transport only, no ScadaBridge impact. Phase 3 (merge d01b0695) added config fetch-and-cache: behind the ConfigSource:Mode dark switch (Direct default = today's behaviour), a driver node fetches its deployed-configuration artifact from central over a gRPC stream (SHA-256-verified against the dispatch's RevisionHash), caches it in ZB.MOM.WW.LocalDb, and reads config only from that cache — never central SQL. Consumer-relevant: this extends the #485 availability guarantee — a FetchAndCache driver node boots and serves its last-known-good address space with central SQL Server completely down (restoring from the LocalDb pointer, no central dependency at boot), and a fetch that fails is treated as no answer (apply fails, last-known-good kept), never an empty configuration. The Phase 3 live gate caught + fixed a serve-gate deadlock (central had gated the artifact serve on Status==Sealed, but a deployment seals only after every node acks and a fetching node can't ack until it fetches). Phase 4 (merge 26fad75c, live-gated on docker-dev; Task-9 table drop 3a590a0c) cut the driver-side ConfigDb connection entirely: a driver-only node (driver, not admin) now boots with no ConfigDb connection string at all — config via FetchAndCache into LocalDb, scripted-alarm Part 9 condition state in a replicated LocalDb table, and central persists its deploy acks. Consumer-visible ServiceLevel change (matters to any ServiceLevel-selecting OPC UA client, i.e. ScadaBridge): DB-reachability is retired from the ServiceLevel calc on such DB-less nodes, so a healthy site node now publishes 240/250 even with central SQL Server completely down (it has no DB connection to lose — survive-alone), where a DB-backed node whose ConfigDb went unreachable would drop to 0/100. So during a central-SQL outage a consumer keeps seeing the site nodes as fully authoritative rather than watching their ServiceLevel collapse. The old single-Akka-mesh election scope was unchanged as of Phase 4 (one Primary fleet-wide) — Phase 6 (below) closes that. Phase 5 (merge 35552a96, live gate PASSED 2026-07-23) replaced the DPS fan-out of the four live-telemetry observability channels (alerts/script-logs/driver-health/driver-resilience-status) with a gRPC server-streaming contract — each driver node hosts the telemetry server and central dials in — behind the Telemetry:Mode/TelemetryDial:Mode dark switch (Dps default), fail-closed shared-bearer auth; redundancy-state/fleet-status/deployment-acks deferred with rationale. Not consumer-facing to ScadaBridge — this is central↔node AdminUI observability, not the OPC UA address space, with no wire-contract change. Also pinned System.Security.Cryptography.Xml 10.0.10 — the same four NU1903 advisories that broke ScadaBridge's fresh restores (dced0d27) were breaking OtOpcUa's too (204 errors on a clean restore, invisible locally because NuGet audit data is cached). Phase 6 (MESH PARTITION — merged to master + pushed 2026-07-24, tip 2839deb5; live gate RAN and caught three fix-forward defects — a boot-crash StackOverflow on every node from resolving IClusterRoleInfo inside the AddAkka configurator while the ActorSystem was still building, plus rig LDAP-missing on sites and a site-pair split-brain — all fixed, 792f28ec) split OtOpcUa's single fleet-wide Akka mesh into one independent 2-node mesh per application Cluster (central pair + one pair per site), the same shape ScadaBridge already runs — same otopcua ActorSystem name, separated purely by per-pair self-first seed partitioning, with central reaching each site mesh over the explicit ClusterClient/gRPC/ConfigServe transports (Phases 2/3/5) instead of shared gossip. Consumer-visible (ServiceLevel-selecting clients, i.e. ScadaBridge): the redundancy election is now scoped per application Cluster, not per Akka mesh — so there are now two-or-more Primaries fleet-wide, one per Cluster, by design (RedundancyStateActor is now a cluster-{ClusterId}-scoped singleton on every driver node). A consumer selecting by ServiceLevel therefore correctly sees one Primary (250) + one Secondary (240) per application Cluster, resolving the "one Primary fleet-wide" scope caveat noted under Phases 0b/4. No OPC UA wire-contract change — this is internal Akka topology + AdminUI/redundancy, not the address space. Also new: a fail-closed SplitTopologyTransportValidator refuses to boot a cluster-role node left on a DPS transport; secrets replication is now pair-local (no SQL-hub — sites have no SQL); the compiled transport-mode defaults stay Dps (the validator, not a default flip, is the guardrail). Plan OtOpcUa/docs/plans/2026-07-24-mesh-phase6-partition-and-colocation.md. Phase 7 (c50ebcf7, 2026-07-24) closed the program: 6/6 failover drills PASSED across all three pair types (graceful manual failover, auto-down both directions, crash-the-oldest, cold-start-alone, rejoin-as-Secondary), closing the auto-down gate open since Phase 0a. The manual-failover button is MAIN-only by construction (it acts on the local node's Cluster.State and the AdminUI runs only on the central pair) — site pairs fail over via auto-down only. A follow-on bootstrap guard (d1dac87f, opt-in Cluster:BootstrapGuard:Enabled, default OFF) closes simultaneous-cold-start split-brain: the lexicographically lower host:port node is the preferred founder, the higher node TCP-probes its partner and joins if reachable / self-forms if not — the order is chosen before the single JoinSeedNodes, so it can never re-form mid-handshake. Deferment register (2026-07-27, e08855fb): OtOpcUa/deferment.md — a source-verified inventory of unfinished work. Two entries are consumer-relevant to ScadaBridge: (1) node-level ACLs are authored, deployed and NOT ENFORCED — the IPermissionEvaluator/TriePermissionEvaluator subsystem has zero production consumers and OtOpcUaNodeManager never references it, yet operators can author NodeAcl rows that ship in every artifact; actual enforcement is a realm-qualified write gate plus an alarm-ack gate, so reads are ungated and authored deny rules have no effect (do not assume OtOpcUa filters an address space per-user). ⚠️ Changed 2026-08-01 (Gitea #520, merged to master + pushed, merge fbe7403c): the OPC UA data plane no longer uses LDAP at all. It is machine-to-machine, so a client presents an OPC UA UserName token with the fixed username apikey and an otua_<keyId>_<secret> credential as the password, minted on the AdminUI /api-keys page (shown once). LdapOpcUaUserAuthenticator is deleted; LDAP survives for AdminUI login only. This does not break ScadaBridge today — OpcUa:AllowAnonymous still defaults to true, and an anonymous session is now a normal grantable principal (anonymous) rather than a bypass, arriving at the same role gates a key does; set it false to make keys mandatory. A key's grants still collapse into the two server-wide gates and only Cluster-scoped grants count — honouring a narrower grant's flags while unable to honour its scope would turn "WriteOperate on Equipment X" into server-wide write authority. Keys reach a driver-only node inside the deployment artifact (the peppered HMAC only, never a secret), so revocation is asymmetric: immediate on a node with a ConfigDb, next-deployment on a driver-only one. (2) a driver-reported remote change is an operator PROMPT, never an address-space rebuild (feat/deferment-remediation, 2026-07-27, Gitea #518) — IRediscoverable was inert fleet-wide and is now consumed: a Galaxy redeploy / TwinCAT symbol-version bump / MTConnect Agent restart / Sparkplug rebirth raises a "re-browse" chip on the AdminUI /hosts page (carried on DriverHealthChanged.RediscoveryNeededUtc/.RediscoveryReason). It is advisory: the served address space is deliberately NOT changed, because v3 authors raw tags through the /raw browse-commit flow — so the served tree still only changes on a config redeploy, which makes it more stable than the driver docs imply, not less. Gitea #507 (discovered-node injection) is closed as superseded and its code deleted — it read two structures that are structurally empty in v3, so it could never have injected anything. IHostConnectivityProbe is now consumed (Gitea #521, merged master abda6f9e 2026-07-30, live-gated on docker-dev): eleven drivers computed per-host connectivity that nothing read. It now rides DriverHealthChanged.HostStatuses to a Hosts column on the AdminUI /hosts page, showing the case the driver-level state chip structurally cannot — a multi-device driver staying aggregate-Healthy while one of its PLCs is unreachable. Resolved neither way the issue framed it: the DriverHostStatus table's intended publisher (driver nodes upserting rows) was unbuildable after Phase 4, since a driver-only node has no ConfigDb connection — so the capability was kept, the transport moved to the Phase 5 telemetry stream, and the permanently-empty table was dropped. Not consumer-facing to ScadaBridge — AdminUI observability, no OPC UA wire-contract change. That register's remediation is MERGED to master and pushed (2026-07-28, merge 914d70bf) — all six ranked items plus two deferred follow-ups: ACL non-enforcement made explicit in docs + UI warnings (wire-up = Gitea #520), the rediscovery signal above, #516 driver config edits no longer silently discarded (a changed DriverConfig now stop+respawns the driver instead of an in-place delta that five drivers ignored while the deploy sealed green — operationally visible: a config edit now costs a driver reconnect), AdminUI dispatch-map parity guards, plan bookkeeping, and the driver stability tiers documented as inert (every driver runs Tier A), and that machinery has since been DELETED (Gitea #522, merge abda6f9e 2026-07-30) — MemoryTracking, MemoryRecycle, ScheduledRecycleScheduler, IDriverSupervisor, the vestigial DriverTypeRegistry, and the operator-authorable RecycleIntervalSeconds knob are gone. Deleted rather than activated because the premise was gone, not merely unused: "Tier C" meant an out-of-process driver Host a supervisor could restart, and none exists anywhere (Galaxy is gRPC-to-mxaccessgw since PR 7.2, FOCAS in-process since 2026-04-24), so IDriverSupervisor had zero implementations. The inertness was also broader than filed — nothing constructed the trackers outside their own unit tests, so "pass real tiers" was never a flag flip. DriverTier itself survives and is load-bearing (GetTierDefaults supplies the real per-capability timeout/retry/breaker policies). Two follow-ups the first pass had deliberately left unbuilt also landed: Sql/FOCAS now re-parse config in place too (each derives its connection/backend from config, so the parse was made atomic — options + dialect + connection string, or options + backend, adopted together — rather than half-applying a change), and tag-set drift detection (FOCAS/TwinCAT/MTConnect/AbCip re-browsed every 5 min and raised the same /hosts prompt when the device's tag set differed from what was authored) — which was REMOVED two days later and is NOT in master (Gitea #523, merge 2a0461a1, 2026-07-30): the check was recurring, unconfigurable (interval was a constructor parameter with no appsettings key) and never live-gated outbound traffic to real PLCs/CNCs/Agents, and how often to browse someone's plant equipment is an operational decision rather than an implementation detail. Consequence to carry forward: AbCip and FOCAS browse a live backend but implement no IRediscoverable, so they now have no tag-change signal at all — a PLC re-download is invisible until an operator re-browses by hand; Galaxy/TwinCAT/MTConnect/Mqtt raise natively and are unaffected. Not consumer-facing to ScadaBridge either way — no OPC UA wire-contract change, and the IRediscoverable prompt above is untouched. That same round (merge 2a0461a1) also retired 13 stale skipped tests (6 revived onto the v3 raw/UNS shape, 3 folded into a widened parity corpus, 4 deleted as retired), added a reflection guard over the deploy-time TagConfig gate after finding MQTT shipped an Inspect() nobody called, fixed three OpcUaClient integration tests that had been silently failing on master (v3 made the driver resolve references through its authored RawTags table, so the tests' bare ns=3;s=… node ids failed locally at the resolver and never reached the wire), and wrote the two missing driver guides (docs/drivers/Sql.md, docs/drivers/Calculation.md — Sql had shipped with no user-facing doc at all). A third tracker round merged 2026-07-31 (9fa701b1): Gitea #526 split ISqlDialect/SqlServerDialect into a Driver.Sql.Dialect leaf so the Sql browser no longer drags the runtime driver — and, through it, ZB.MOM.WW.OtOpcUa.Core — into the AdminUI's build/deploy graph (measured absent before/after; this was the remaining cause #512 had wrongly attributed to MQTT), guarded now by a csproj-graph test over every *.Browser project; #525 deleted the unconsumed IDriver.GetMemoryFootprint / FlushOptionalCachesAsync across all 12 drivers plus the support code that only fed them; #481 bridged driver comms-loss into scripted-alarm input quality and made the engine distinguish a never-pushed input from a driver reporting Bad. Consumer-visible to ScadaBridge, with a caveat: a scripted-alarm condition whose input reports Bad with no value now reports Bad Quality on the wire where it previously read Good, and virtual tags computed from such an input go Bad too — the comms-loss half was initially unreachable, because the #481 live gate found that DisconnectObserved had no production sender: a driver whose device died mid-run was never detected, never reported comms loss, and did not recover when the device returned (live-observed on Modbus; a node restart was needed). That is now FIXED and the caveat is withdrawn — Gitea #527, merged master 916f0f79 2026-07-31, live-gated twice on docker-dev. A connected driver whose device dies is declared disconnected once it has gone 90 s without a successful read while also reporting itself non-Healthy, which emits ConnectivityChanged(false) and drives both quality paths (#477 native conditions, #481 scripted-alarm inputs), and it re-initialises and recovers by itself when the device returns (~8 s in the gate, no node restart). So OtOpcUa condition Quality can now be relied on to signal that a PLC went away — with the honest bound that detection is delayed by up to ~2 minutes by design, not instant. The trip condition is deliberately a conjunction rather than "non-Healthy for N polls": Degraded means connected but some data is bad, and drivers that write health per-tag inside their read loop latch Degraded against a perfectly healthy device when one authored address is unreadable, so a state-only rule would have reconnected working drivers every 90 s forever. Also fixed in the same change: the reconnect retry now tears the transport down before re-initialising, since Modbus and MQTT overwrite an IAsyncDisposable field in InitializeAsync and would otherwise leak a socket per retry. AB CIP / AB Legacy honoured the wrong TCP port until 2026-08-01 (Gitea #534, merged master 94990990): both libplctag drivers carried the authored EtherNet/IP Port on their tag-create params and consumed it nowhere, and libplctag does not fault on a portless gateway — it substitutes 44818. So a device authored on a non-default port was not merely unreachable; the connection was silently redirected to whatever answers on 44818, which on a plant network is a different PLC whose tags would then read Good. Anyone who concluded that AB devices must live on 44818 can stop working around it. The discovery half of that defect survived until 2026-08-06 as Gitea #539, now FIXED (merge 5b9ff9d0): the AbCip “Browse controller” path still passed the bare host to libplctag, so browsing a device authored on a non-default port silently returned the symbol table of whatever answers on 44818 — an operator could commit tags against the wrong PLC. Both discovery sites (symbol-list enumerator + UDT template reader) now compose host:port, a source-scan guard test pins every libplctag Tag construction in both AB drivers to the composed form, and the fix was live-gated against the fixture host (browse on :44818 returns the nine-tag table; browse on a dead :44999 — the issue's exact repro — now fails instead of redirecting). Found only because the AbLegacy PCCC test fixture had never actually been deployed — it now runs one simulator per family on its own port, and that suite passes for the first time. The FOCAS fixed tree became publishable for the first time on 2026-08-05 (Gitea #540, merged master 4e22f024): browse-committing a fixed-tree leaf produced a tag the driver could never read, so the whole Identity/Axes/Spindle/Program/OperationMode/Timers surface could not reach the address space through any v3 authoring path — v2 auto-injected it and #507 removed that injection as superseded by a browse-commit route that did not work. A captured browse leaf's id is its driver FullName, and the fixed tree emitted its internal cache key {deviceHost}/{path} there; both authoring paths wrote that verbatim into the tag address, no FOCAS parser accepts it, and the driver skipped every such tag at init — so all 20 committed tags on the wonder Makino read BadNodeIdUnknown forever while the deploy sealed green and the driver reported Healthy with 0 errors. Fixed at the emitter rather than by stripping the host at commit, which repairs the /raw browse-commit and the manual picker together: a fourth address form FIXED:<path> (e.g. FIXED:Identity/SeriesNumber) served from the driver's own poll-loop snapshot, read-only, and carrying no device — the tag's Device row already routes it, and the duplicate routing channel was the root cause rather than the parse failure. Consumer-visible to ScadaBridge: new FOCAS nodes can now appear where the fixed tree is authored (they could not before), they are ViewOnly, and their quality is honest — values are dated from the last answered poll and go Bad after 8 poll cadences (floored at 30 s) instead of serving a dead CNC's last snapshot as Good indefinitely. Two authoring-side guardrails shipped with it (the tag modal and the deploy-time inspector now run the real address parser, so a bad address is rejected where it is authored rather than silently skipped at driver init), and the manual picker — which had offered axis/spindle/program/status "parameter groups" that are not FOCAS addressing spaces, defaulting to the unusable axis:5 — now composes PMC/PARAM:/MACRO: through the parser itself. The other half of #540 was split out as Gitea #541 and is now FIXED (merged to master + pushed, merge 355e59aa, 2026-08-06, live-gated on docker-dev over two rounds). Every driver skipped an unmappable tag at init with only a log line, so a driver could report Healthy / 0 errors / a live-advancing Last read while ignoring most of its tags — that is no longer invisible. A new optional ISkippedTagReporter capability over a shared SkippedTagLedger carries the authored count, the exact skipped count and a capped sample of RawPath + reason on DriverHealthChanged, surfacing as a Tags column on the AdminUI /hosts page (20 / 22 skipped; red when a driver is polling nothing, which is the state most likely to look healthy because it has no failing reads to report). Transport is the Phase 5 telemetry stream, not a table — the #521 lesson, since a driver-only node has no ConfigDb. The issue's own count was wrong: it named eight drivers, and it is twelve — Calculation, MTConnect, Galaxy and OpcUaClient were unlisted, and OpcUaClient's was the quietest of the twelve (a bare continue in NamespaceMap with not even a log line). A reflective fleet guard over every IDriver now pins the invariant so a thirteenth driver cannot re-introduce the silence. Sql reports the union of its two drop points — the blob that does not map and the §8.1 catalog gate — because schema drift (a renamed column, a dropped view) produces the second, and reporting only the first would have it claim 0 skipped while polling half its tags. The signal is additive to driver state, never Degraded: latching non-Healthy for a config problem would satisfy both halves of #527's comms-loss conjunction and reconnect-loop a perfectly healthy device whose tags were all skipped. Not consumer-facing to ScadaBridge — AdminUI observability, no OPC UA wire-contract change; a skipped tag's node still materializes and still reads BadNodeIdUnknown exactly as before. What changes is that an operator can now see it. The live gate found one defect the unit tests could not (a driver that faulted before building a tag table reported 0 tags against its real authored count — a positive claim made on behalf of a driver that never looked; GetSkippedTags() now returns null until the first committed build), and surfaced three drivers on the dev rig that had been silently skipping their tags for an unknown length of time, each reporting Healthy with zero errors. The duplicate-sibling-axis residual (the wonder Makino returns two axis folders both called AA, which the commit gate refused and whose name-keyed runtime cache let the second overwrite the first) is FIXED as Gitea #548 (merge 8b5c478c, 2026-08-06): duplicated sibling names — axes AND spindles — are occurrence-suffixed in slot order (AA_1/AA_2; unique names stay bare, so nothing renames on ordinary machines), the emitter/snapshot-keys/servo-loads/spindle-resolution all consume one shared segment list so they cannot disagree, and the bare name of a duplicate set deliberately reads BadNodeIdUnknown rather than one arbitrary axis's data. Red-first offline tests plus a live focas-mock wire gate; not yet deployed to wonder (the box runs 4e22f024) — authoring the Makino's Axes/ needs that deployment plus a re-browse. #540 itself is CLOSED as of 2026-08-06. Deployed to wonder-app-vd03 the same day (box 4e461951 → 4e22f024; no EF migration in the delta, so a plain binary swap): the 20 dead FOCAS tags had their addresses rewritten in place to the FIXED: form rather than being re-browsed — an exact 1:1 transform that preserves every RawPath/Name/DataType/TagGroup/UNS reference, so nothing downstream rebinds — and they now read 20/20 Good over OPC UA with real machine data (Series G431, 7 axes, mode AUTO, program //CNC_MEM/USER/LIBRARY/O1234), where they had been 0/20 since the v3 cutover. Nothing else in that merge was consumer-visible: the NodeAcl scope model gained a variable-depth NodeScope plus the scope-path producer its trie builder had always required (Gitea #520 stage 3). Node ACLs ARE now enforced, but only behind an opt-in flag (Gitea #520 stage 5, master 2026-08-01). Security:Acl:Enabled defaults to false, and with it off nothing changes for a consumer: the two coarse server-wide gates (WriteOperate on Value writes, AlarmAck on Part 9 methods) remain the whole of data-plane authorization and HistoryRead stays ungated. With it on, scoped NodeAcl grants decide EVERY data-plane operation — read, browse, subscribe, write, alarm commands and HistoryRead (#520 stages 5 + 6, master 2026-08-01, live-gated on docker-dev) — and the scoped decision replaces the server-wide role rather than adding to it. What this means for ScadaBridge — this changed on 2026-08-01 and the previous entry said the opposite: reads, browses and subscriptions are ungated only while the flag is off. On a node where an operator turns it on, a read-only consumer is affected too: every operation ScadaBridge performs — browsing the address space, reading, subscribing, and its writes / alarm acks / HistoryRead — is denied unless the principal it authenticates as holds a matching grant — and since OpcUa:AllowAnonymous still defaults true, that principal is the reserved anonymous one unless ScadaBridge is given a key. Granting anonymous the permissions it needs, or issuing it an otua_ key and granting that principal, are both first-class options. Two consequences of stage 6 specifically. Browse is needed to navigate, and a grant deep in the tree automatically makes the folders above it browsable — otherwise the grant could not be reached — so a narrow grant still exposes the names of its ancestors. And revoking a grant now stops a running subscription: the monitored item receives one BadUserAccessDenied and then goes silent, and a later deployment restoring the grant resumes it with no client action, so ScadaBridge does not need to reconnect to recover. Both were verified live on docker-dev, not only in tests. Two behaviours worth knowing: an ACL or key change reaches sessions that are already open (each decision stamps the current deployment generation and re-checks the key, so revoking a key stops its live session rather than waiting for a reconnect), and enforcement fails closed on every unresolvable case — a misconfigured node denies everything rather than allowing everything, which is the safe direction but presents as a total outage of the three gated operations rather than a subtle permission bug. #520 is now COMPLETE (stage 7, master 3796c0bf, 2026-08-01, live-gated on docker-dev): grants are authored through a scope picker over the cluster's real raw + UNS trees, which supplies the scope kind and target together, and both the AdminUI and the deploy gate now refuse a grant whose scope resolves to nothing. Not consumer-facing to ScadaBridge — authoring-side only, no OPC UA wire-contract or enforcement change; it matters only in that a grant issued to ScadaBridge's principal can no longer be saved pointing at an entity that does not exist. Tracker zeroed 2026-08-10 — everything below is merged to master and pushed (origin now at d06e92c6; the repo had run ~45 commits ahead of origin over 2026-08-07→10 and is back in sync). Six subagent rounds closed Gitea #549–#559, completing the authoring-vs-deploy-gate parity arc: every driver's AdminUI tag editor, the CSV import/export merge, the /raw authoring service, browse-commit, and the deploy-time Inspect gate now all consult the driver's own runtime parse as the single verdict authority (TagConfigParseGate; accept-gate-first Inspects with rejection-vs-advisory classified structurally), so what any authoring path accepts is exactly what the driver serves — the "authored green, reads BadNodeIdUnknown forever" class is closed fleet-wide. Two served-behaviour fixes are consumer-visible to any OPC UA client: a leading-whitespace MQTT TagConfig blob is now served instead of silently refused (#559 — MQTT was the one driver whose runtime mappers carried their own untrimmed framing gate; JsonDocument.Parse is now the fleet-wide framing authority), and an OpcUaClient tag authored with the legacy PascalCase NodeId key now survives an editor round-trip (#557 — the editor previously dropped the key on save, taking the tag dark on the next deploy). One deliberate authoring tightening: an untouched {} Modbus config is refused at save (#554 — it always read BadNodeIdUnknown under a green deploy). Also in that window: connect-path resilience — IDriver.InitializeAsync now runs through the per-capability invoker (timeout/breaker, DriverCapability.Initialize; R2-10, merge 1038c0e2), closing the last uninvoked driver entry point, with the breaker threshold made cadence-reachable and a respawn invalidating the breaker tracker so a mid-outage deploy cannot strand a stale OPEN chip on /hosts. And a plan-task freshness sweep (2026-08-10, d06e92c6) live-verified Modbus-Int64 end-to-end authoring and the S7/AbCip Test-Connect probe E2E (12/12; the probe suite's skip-gating turned out to be pure TCP reachability — runnable from macOS, the "needs Windows-VM" note was stale) and closed the two plan tasks whose subjects v3 had deleted; open plan tasks are down to 3, all deliberate holds. None of the above changes the OPC UA wire contract beyond the two served-behaviour fixes named. |
MxAccessGateway (mxaccessgw) |
~/Desktop/MxAccessGateway |
.NET 10 gateway (x64) + .NET 4.8 worker (x86), gRPC | gitea.dohertylan.com/dohertj2/mxaccessgw |
gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. OtOpcUa depends on this. |
| ScadaBridge | ~/Desktop/ScadaBridge |
.NET 10, Akka.NET, Docker | gitea.dohertylan.com/dohertj2/ScadaBridge |
Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed ZB.MOM.WW.ScadaBridge.*; solution ZB.MOM.WW.ScadaBridge.slnx. Ships src/, tests/, docker/ topology, and the design docs that are the spec. |
| HistorianGateway | ~/Desktop/HistorianGateway |
.NET 10 x64, gRPC, Blazor | gitea.dohertylan.com/dohertj2/historiangw |
Single-process gRPC sidecar exposing (1) full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared ZB.MOM.WW.GalaxyRepository lib (consumed as a Gitea-feed package). No COM, no x86 worker. Dev: two plaintext endpoints from appsettings.Development.json — dashboard on :5220 (HTTP/1.1), gRPC h2c on :5221. Production: single Kestrel:Endpoints:Https endpoint with Protocols: Http1AndHttp2 multiplexes dashboard + gRPC over one TLS port (ALPN); warn-only if no TLS endpoint configured (valid behind a reverse proxy / Kubernetes ingress; the warn predicate covers any non-Development environment, i.e. Production + Staging). In a non-Development environment the gateway also logs warn-only production-readiness checks (pending.md D2/D3) — relative runtime-artifact paths + secret hygiene (ApiKeys:Mode=Disabled, empty/dev-placeholder pepper, dev-placeholder LDAP password). Owns AVEVA.Historian.Client — imported into the repo at histsdk/ (subtree, folded in with history 2026-06-28; the old "vendored / re-vendor from upstream" model is retired — histsdk/** is ordinary owned source, edited in place; the gateway builds it directly). Store-forward uses a crash-safe FasterLog append-only outbox (Microsoft.FASTER.Core 2.6.5; CommitMode PerEntry/Periodic), not SQLite. Handshake amortization (pending.md A1) done + live-validated — a default-on leased-session pool (Historian:SessionPool) reuses pre-authenticated sessions across reads/writes/status ops/tag-browse/metadata (~4.7× measured; probe and blocks stay per-call), with a <~15 s keepalive + reactive re-auth, surfaced via a PooledHistorianClient facade so services are unchanged; the HistorianSession primitive lives in the owned AVEVA.Historian.Client under histsdk/; browse/metadata + SendEvent broadening merged to main + pushed. SendEvent is also amortized via a separate, parallel event-session pool (Historian:EventSessionPool, default-on; v8/ECDH auth — kept distinct from the v6 pool), warranted by a GREEN v8 Event-session reuse spike (~10–16×); ReadEvents stays per-call / gated (C2). The full offline suite is green on macOS (0 warnings); the env-gated live historian + Galaxy integration suite exercises the amortized path and otherwise skips without a live server. Part C minimal (pending.md C1/C3a) merged to main: Int8/UInt8 live write types un-gated + live-proven against wonder-sql-vd03 (in the owned histsdk/ SDK); the 2023 R2 gRPC interface-version integers recorded as evidence (C3a); UInt1 attempted but server-blocked (the historian accepts EnsureTags(UInt1) yet stores a degenerate analog tag) → re-gated fail-closed. C2 closed won't-fix (2026-06-26): event reads are server-gated on the 2023 R2 historian over both transports — gRPC retrieval-server-gated (0 rows scoped to a managed connection), and the WCF certificate transport + auth DO reach the historian cross-platform (CM_EVENT registers on the 0x501 event connection) but the query still returns 0 rows: the same server-side per-connection row gate. (An earlier note saying "WCF not served on 2023 R2" was a test error vs the reverse tunnel.) Not client-fixable. Reusable SDK wins: ConnectViaAddress (WCF Via for tunneled access), EventReadConnectionModeOverride. SQL-path ReadEvents (merged 2026-06-26) — the practical workaround for the C2 gate: event reads now ship via the historian's Runtime.dbo.Events SQL view (config-gated RuntimeDb:EventReadsEnabled + EventReadMaxRows), mirroring the SQL live-write path (no COM/native); live-proven streaming real events (incl. an INSQL NOT-NULL CAST fix the live test caught) while the native event-query stays gated — so event reads work despite the native dead-end. All of Part C + C2 + SQL-ReadEvents are merged to origin/main (gateway @ fabab1a, histsdk @ f0a1b04). Follow-ups merged to main (2026-06-27): historiangw PR #6 — WriteLiveValues UTC→server-local timestamp fix (live-validated exact) + a documented SendEvent Source_Object protocol limitation (pending.md C4); the Plan-1 .NET client lib + packable Contracts (PR #5); and a C2 cross-ref docs PR (#4). First consumer (merged): OtOpcUa PR #423 (→ master) adopts ZB.MOM.WW.HistorianGateway.Client as its sole historian read/write backend, retiring its bespoke Wonderware historian driver. Local Docker (2026-06-29): HistorianGateway/docker/ runs the sidecar in a container against the REAL wonder datasources (historian wonder-sql-vd03:32565 TLS + Galaxy SQL wonder-app-vd03/ZB), dashboard login + gRPC API-key auth disabled — verified /health/ready Healthy. Runtime-only image: publish framework-dependent on the host (authed Gitea feed → no Docker restore), COPY into aspnet:10.0; secrets via gitignored env_file; isolated compose project zb-historiangw; container egress needs the host VPN up. Client surface (clients/dotnet/): the published ZB.MOM.WW.HistorianGateway.Client lib + a ZB.MOM.WW.HistorianGateway.Client.Cli smoke CLI (probe/read-raw/browse, JSON out; in the .slnx, not packed). Current: origin/main @ 56a70df — AHC-002 string READS landed via native RE from the Mac (PR #17 merged 2026-07-16): the SDK now parses string historian result-buffer rows into HistorianSample.StringValue (a per-row valueType discriminator — 1=numeric→double, 3=string; value = u32 charLen + UTF-16LE), and the gateway forwards string_value through HistorianRead.ReadRaw — no contract change, no package bump (the proto already carried the field; mapper already forwarded it); live-proven against 2023 R2. String tag-create is now doable via SQL aaStringTagInsert (SqlStringTagProvisioner, test tooling — single + double byte). String historical writes were probed (bounded 6-candidate native blob probe) but non-convergent — tag-info resolves a real GUID + SingleByteString (unlike the Int1/UInt1 degenerate stub) so writes aren't blocked at resolution, but BSuccess is not an oracle (all framings optimistically accepted) and none round-tripped → deferred, needs a native-client write wire capture (blind guessing can't finish). Eight follow-up issues filed (historiangw #18–#25): datatype gaps (native string tag-create #18, string writes #19, DateTime/FileTime #20, Guid/Structure/Event #21, Int1/UInt1 discrete-create #22 [follow-on to #11], DoubleByteString read-verify #23) + SQL-fallback trackers (event reads via Runtime.dbo.Events #24, live writes via History/v_StringHistory #25). Also folded a doc-truth reconcile of the "Key structural decisions" bullets (GalaxyRepository PackageReference, Contracts/Client 0.3.0 published, OtOpcUa on 0.3.0). Prior: ArchReview #2 (cycle-2) remediation MERGED 2026-07-14 (PR #12): all 8 workstreams A–H — store-forward drain honesty (IsRetryableDeliveryFault, typed HistorianOutboxFullException→ResourceExhausted), streaming-lease caps (per-key cap 8→4, MaxStreamLeaseHold, bounded EndQuery teardown, client StreamTimeout default null→10min), a live idle-out capture (GREEN-A, native (type 4, code 51)) that narrowed the session-expiry classifier and closed the stability-F5 tail, histsdk residue (typed event-send FromNativeError, HistorianProtocolIntegrityException, IP/host scrub), read-path caps (MaxAggregateIntervals/MaxAtTimeTimestamps, 5M→ResourceExhausted, verify-once-per-connection version gate, batched audit drain), release hygiene (csproj <Version> sole authority + packed-README/AnyCPU/FQDN/CHANGELOG guards), security/ops (opt-in ForwardedHeaders trust list, k8s key-bootstrap runbook, dashboard pool/dead-letter signals), and doc-truth #2 (Historian:VerifyServerInterfaceVersion now a real fail-closed key). Verified 0-warn, offline 1388 pass + live suite green vs wonder-sql-vd03. Contracts+Client 0.3.0 PUBLISHED to the dohertj2-gitea feed (2026-07-14; carries the StreamTimeout default change) and OtOpcUa bumped to 0.3.0 (lmxopcua master @ 7f79cd59, PR #440 — only consumer delta is the StreamTimeout default; driver tests 103/0). Boolean historization remapped Int1→Int2 (2026-07-14, lmxopcua master @ 38e21df2, PR #442, closes #441): the R2-06 live gate surfaced that the historian's Int1 analog-tag creation path is server-degenerate (EnsureTags(Int1) stores an unusable stub) — filed gateway-side as historiangw #11, documented there as an explicit evidence-based exclusion (PR #16), and routed back to OtOpcUa as issue #441. OtOpcUa's HistorianTypeMapper now maps Boolean → Int2 (0/1 integer); 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 and a pre-existing Float Boolean tag retypes cleanly (both are supported analog types). Closes the last R2-06 red test → live gate 6/6; gateway driver unit 103/103. archreview round-2 deferred gates closed 2026-07-14 (lmxopcua master @ 30672888): R2-04 T13+T15 LIVE-PASSED (PR #444) — the behavior-affecting S4 primary-gate default-deny verified on a 2-node docker-dev rig (ServiceLevel 250/240; boot-window write rejected not primary (role unknown) + denial meter reason=role-unknown; steady-state secondary rejected + node reverted; primary write reaches device) plus the in-process PrimaryGateFailoverTests (delivered-snapshot drives the gate); and the pre-existing red S7.Cli phrase-scan test fixed (PR #443, restored a stripped rationale comment). Round-2 remainder is now only infra-gated: R2-08 live-GLAuth outage, R2-01 S7 SYN-blackhole. The full *.IntegrationTests sweep is now COMPLETE (2026-07-15, lmxopcua master @ 152a5645): ran all 10 driver + server integration suites serially (on the Mac against native fixtures on the 10.100.0.35 Docker host), then closed all 7 resulting follow-ups (PRs #446–#452) — every driver + server integration suite is verified GREEN with ZERO OtOpcUa production regressions; every original "failure" was fixture/harness/test rot, not product. Highlights of cross-repo relevance: (a) the Host integration harness's LDAP swapped from the retired bitnami/openldap:2.6 to GLAuth (PR #451) — the harness now runs its own ephemeral GLAuth container on :3894 (seeded by tests/.../Host.IntegrationTests/glauth/config.toml, distinct from the shared dev GLAuth on :3893), unifying the whole family's opt-in real-LDAP path on GLAuth and removing the lone OpenLDAP outlier; (b) the deploy-E2E "amd64-emulated-SQL timing" complaint was misdiagnosed — those tests had only ever run against the EF in-memory provider (no FK enforcement) and lacked the ServerCluster/ClusterNode seed the real-SQL NodeDeploymentState FK requires; fixed with a harness seeding helper + OTOPCUA_HARNESS_SQL_HOST/_LDAP_HOST overrides so the fixtures run on the native-amd64 Docker host (PR #452). See archreview/plans/INTEGRATION-SWEEP-STATUS.md for the full record. Cycle-2 gated tail: CI first-green-run (needs a Gitea act_runner), the histsdk protobuf/Grpc.Tools major bump (03-CC-8, blocked on migrating off obsolete GrpcWebHandler.HttpVersion), and the 1.0.0 TLS-default flip. The prior cycle-1 ArchReview remediation is fully merged (Now + Next tiers 2026-07-09 @ 508b9c3; Later tier 2026-07-13 via a coordinated two-PR merge, 12385c8 + 6a24a30). Off a 2026-07-08 six-report architecture review (archreview/) turned into 8 workstream plans (docs/plans/2026-07-09-archreview-*; tracker ...-remediation-tracking.md), shipped clean under TreatWarningsAsErrors: a hard-fail ResilienceComposition validator (StoreForward+Redundancy both-on), corruption-tolerant outbox + dead-letter/overflow knobs, /auth/login rate-limit, server-side max_values cap, a headless apikey CLI, histsdk parser hardening, Gitea Actions CI + warnings-as-errors on all trees, pool observability (lease-wait/waiter/counters) + session-pool health probe, and a typed histsdk fault taxonomy with type-based classification + session churn-guard. Contracts+Client 0.2.0 published to the dohertj2-gitea feed (2026-07-09; Int1 proto3-optional, opc_quality doc, CA-chain pin). Cross-repo 0.2.0/G-2 chain COMPLETE: OtOpcUa bumped to Client+Contracts 0.2.0 (2026-07-10, master @ f6eaa267; M3 opc_quality gate clear), and the shared ZB.MOM.WW.Auth.ApiKeys gained ExpiresUtc at 0.1.4 — consumed by HistorianGateway (apikey create --expires + dashboard Expires column) and mxaccessgw (verifier enforcement). Later tier (roadmap 16–21) MERGED to main 2026-07-13 (coordinated two-PR merge; both branches now deleted): PR #8 (archreview-later-21-and-maxsessions, merge 12385c8) = Later-21 (audit off the mutating-RPC critical path + WriteLiveValues SQL batching + composed-failure/shutdown tests) + the MaxSessions 4→8 raise (live concurrent-session spike GREEN); PR #9 (later-16-page-through-streaming, merge 6a24a30) = Later-16 ReadRaw page-through streaming (streams per server result buffer; pool holds the lease across the enumeration; new Historian:ReadLimits:AllowStreamingBeyondCeiling), Later-17 redundancy honest-hardening (exposes the Redundancy:WriteAcknowledgement/WriteFanout/FailureThreshold ack policy the docs already claimed), Later-18 per-key concurrency cap (ApiKeys:MaxConcurrentCallsPerKey → gRPC ResourceExhausted) + api-key-rotation runbook, Later-19 CHANGELOG-discipline guard test, Later-20 K8s manifests + hardened prod Dockerfile + operator runbooks (deploy/k8s/, docker/Dockerfile.production, docs/runbooks/). The two PRs conflicted on CLAUDE.md/tracking/ValidatorTests, resolved as the union (MaxSessions lands at 8). Live-proven vs wonder-sql-vd03; offline gateway 729 pass / client 140 pass / 0 warnings. Only remaining archreview item: the secure-by-default TLS-default flip, deferred to 1.0.0 (pre-announced in the client 0.2.0 CHANGELOG). (Prior tip 93c6051 = the 2026-07-07 CommentChecker XML-doc sweep.) |
Cross-project relationships
The three indexed projects are separate repos and separate processes, coupled at
runtime over wire protocols (gRPC + OPC UA) — not by project/compile references. They
share the ZB.MOM.WW.* product namespace (ZB.MOM.WW.OtOpcUa.*, ZB.MOM.WW.ScadaBridge.*;
the gateway uses MxGateway.*). The common subject is AVEVA System Platform (Wonderware)
"Galaxy" data, and mxaccessgw is the linchpin that the other two connect through.
Data flow
AVEVA System Platform — Wonderware "Galaxy"
(OT source of truth: runtime tags + Galaxy Repository SQL DB)
▲
│ MXAccess COM (32-bit, STA message pump)
│
┌────────────────┴─────────────────┐
│ MxAccessGateway (mxaccessgw) │ gateway x64 .NET10 + worker x86 net48
│ gRPC service; OWNS the 32-bit │ protos: mxaccess_gateway / mxaccess_worker
│ COM bitness + STA pump │ / galaxy_repository
└──────┬─────────────────────┬──────┘
gRPC (MxCommand/MxEvent + │ gRPC (ScadaBridge "MxGateway" adapter:
GalaxyRepository browse) │ native MxAccess data + A&C alarms)
│ │
┌─────┴──────┐ │
│ OtOpcUa │ GalaxyDriver maps │
│ OPC UA srv │ Galaxy hierarchy → │
│ (.NET 10) │ OPC UA addr space │
└─────┬──────┘ │
│ OPC UA (opc.tcp; data + A&C alarms)
▼ ▼
┌──────────────────────────────────────────────┐
│ ScadaBridge — Data Connection Layer (DCL) │
│ OPC UA adapter │ MxGateway adapter │ custom │
└─────────────────────────┬────────────────────┘
▼
Instance Actors → site clusters → central cluster / UI
Edge-by-edge
- MxAccessGateway is the foundation. It is the only component that loads 32-bit MXAccess COM (its x86 net48 worker owns the COM apartment + STA pump). It exposes that to modern x64/.NET-10 callers over gRPC, and also serves Galaxy Repository SQL browse RPCs. This is why the other two exist as .NET 10 / x64 and never touch COM directly.
- OtOpcUa → MxAccessGateway (gRPC client). OtOpcUa's in-process
GalaxyDriver(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/) uses two gateway channels: theGalaxyRepositoryClientfor the static hierarchy, and an MXAccess session (MxCommand/MxEventprotos) for live read/write/subscribe. ADeployWatcherpolls the gateway's deploy-event signal to rebuild the OPC UA address space on Galaxy redeploy. OtOpcUa's job is a protocol bridge: it republishes Galaxy — now bound as a standard Equipment-kind driver alongside its native-protocol drivers, not a special SystemPlatform mirror — as an OPC UA address space (live values, Part 9 alarms, HistoryRead) for any OPC UA client. - ScadaBridge → OPC UA (OPC UA client). ScadaBridge's DCL has an OPC UA adapter that collects data and mirrors native OPC UA Alarms & Conditions. OtOpcUa is exactly such a server, so ScadaBridge can ingest Wonderware data indirectly via OtOpcUa.
- ScadaBridge → MxAccessGateway (gRPC client). The DCL also has a dedicated
MxGateway adapter that talks to
mxaccessgwdirectly for native MxAccess data and alarms — so ScadaBridge can reach Wonderware data directly, bypassing OtOpcUa. Both adapters implement the sameIAlarmSubscribableConnectionseam, and a read-onlyNativeAlarmActorunifies OPC-UA-A&C and MxAccess alarms onto one condition model.
Net effect
mxaccessgwis a shared dependency of both OtOpcUa and ScadaBridge.- ScadaBridge has two paths to the same Wonderware data: (1) OPC UA → OtOpcUa → gateway, or (2) MxGateway adapter → gateway directly. Path 1 gives standards-based OPC UA decoupling; path 2 gives a more direct/native feed.
- HistorianGateway is a near-independent sidecar. It reaches the Historian via its owned gRPC
client (
AVEVA.Historian.Client, imported into the repo athistsdk/) and the Galaxy Repository SQL DB directly, not throughmxaccessgw. It consumes the sharedZB.MOM.WW.GalaxyRepositorylib (Gitea-feed package). Any client that needs Historian data or Galaxy browse can target it independently. As of 2026-06-27 it is a runtime dependency of OtOpcUa (ScadaBridge still has no coupling): OtOpcUa adopted HistorianGateway as its sole historian read/write backend (continuous historization + alarms via the publishedZB.MOM.WW.HistorianGateway.Client), retiring its bespoke Wonderware historian driver — merged to OtOpcUamaster(PR #423). The gateway-side follow-ups also merged tohistoriangwmain: the Plan-1 client lib + packable Contracts (PR #5) and theWriteLiveValuesUTC→server-local timestamp fix + a documentedSendEventSource_Objectprotocol limitation (PR #6,pending.mdC4). OtOpcUa pre-existing test failure tracked aslmxopcuaissue #424. - Coupling is loose: each repo references the others only as sibling context (the
## Sister Projectsnote in ScadaBridge's ownCLAUDE.mdlistsMxAccessGatewayandOtOpcUawith their Gitea URLs but states they are not part of its solution). - The break surface is the wire contracts, not code. Because coupling is by network
protocol, the things that break across repo boundaries are: the gateway's
.protofiles (mxaccess_gateway.proto,mxaccess_worker.proto,galaxy_repository.proto), thehistorian_gateway.v1proto (HistorianGateway's own contract), and the OPC UA address-space shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model). Changes to any of these must be coordinated across the affected repos — a green build in one repo does not prove the others still interoperate.
Component normalization
Because the sister repos re-implement the same cross-cutting concerns separately and drift
apart, components/ normalizes them: per component, the one target spec,
each project's code-verified current state, and the gaps between. See
components/README.md for the convention and workflow.
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Adopted (feed carries 0.2.1, and ALL FOUR apps pin 0.2.1 as of 2026-08-13: OtOpcUa master @ 08e33fc7, ScadaBridge main @ d28824d9, MxAccessGateway main @ 1cb14d2, HistorianGateway e42ff31, merged to main — each built + tested green, all PUSHED to origin 2026-08-13). 0.2.0 (2026-08-10) adds LDAP backup-DC sticky failover, 0.2.1 (2026-08-12) fixes a defect that broke AD logins outright — an AD continuation referral escaped NovellLdapConnection.Search, so any SearchBase at a domain root failed every login as ServiceAccountBindFailed against a perfectly healthy directory. Live-proven against DC=nam,DC=zimmer,DC=com; the app-level exposure is closed by the pin bumps. FallbackServers is live-gated via the local redundant GLAuth pair (infra/glauth-redundant/, primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). |
Shared ZB.MOM.WW.Auth lib |
components/auth/ |
ZB.MOM.WW.Auth/ |
| UI Theme (layout / tokens / components) | Adopted (feed 0.2.0–0.4.1). 0.4.0 (2026-08-11) upstreamed the Bootstrap .btn/.btn-sm sizing block that all five consumers had independently written into their own stylesheets (Bootstrap hard-codes --bs-btn-font-size: 1rem, oversized against the kit's 0.9rem body) — sized by --bs-btn-* variable override only, never by redeclaring box properties, because .btn-group's seam/radius machinery reads those same variables and a literal font-size at equal specificity silently flattens .btn-sm into a padding-only difference (a real defect found in mxgw's local copy). Same release added the rail-btn-block full-width rail-foot modifier; 0.4.1 fixes it — 0.4.0 shipped width: auto, which fills for an <a> but shrink-wraps a <button> (measured 114px short in the same rail), now calc(100% - 1.2rem) to clear .rail-btn's side margins. Consumers are five, not four — the ignitionoee router joined. All five consumers are on 0.4.1 and all five are PUSHED (2026-08-11): ignitionoee main @ 38aa5c1, OtOpcUa master @ e82881c1, mxgw main @ 0a9715d. The two whose sweeps were not deploy-ready pushed to branches, deliberately leaving their default branch untouched: HistorianGateway dashboard-ui-sweep-theme-0.4.1 — MERGED to main (ff to e42ff31) + pushed 2026-08-13, carrying Theme 0.4.1 + Health 0.3.0 + Secrets 0.6.2 + Auth 0.2.1 and ScadaBridge ui/central-ui-cleanup-sweep @ 9e243493 (main was then b6f383a2). ScadaBridge's caveat is now CLOSED and its sweep is merged, deployed and live (2026-08-11): the branch landed on main, and the two follow-ups d14e0ee4 + 3eb7df74 fixed exactly the four detail-modal surfaces the caveat named — the modal is now gated on user intent (the held id), never on the row still resolving, so a background refresh that drops the row leaves it mounted and says why the summary is missing instead of unmounting the subtree and disposing Close's own handler mid-click. That disposal was the intermittent GetRequiredEventBindingEntry, and NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage now pins it. Deployed to wonder-app-vd03: OtOpcUa e82881c1, and ScadaBridge 3eb7df74 (2026-08-11 — theme.css/layout.css/nav-state.js served from _content/ZB.MOM.WW.Theme and referenced by the pages; all six changed routes 200). HistorianGateway on wonder still runs e9c31da = Theme 0.3.1 — its 0.4.1 is now on main (merged 2026-08-13) but not yet deployed to the box. On bump, TRIM the local .btn block, don't delete it — four of the five were pure sizing, but mxgw's also carried border-radius/font-weight/white-space that a blind delete would have dropped. |
Shared ZB.MOM.WW.Theme RCL |
components/ui-theme/ |
ZB.MOM.WW.Theme/ |
| Health (readiness / liveness / active-node) | Adopted (feed carries 0.1.0–0.3.0, current lib version 0.3.0; all 4 apps reference it + wire MapZbHealth). 0.2.0 (2026-07-24) added an optional per-entry data object to the canonical health JSON and made AkkaClusterHealthCheck publish its cluster view (leader/selfAddress/selfRoles/memberCount/unreachableCount) — additive, so pre-0.2.0 payloads still parse. 0.2.1 (2026-07-24) is a behaviour fix with a live consumer impact: the active tier now returns Unhealthy (503) on a role-member-but-not-leader node instead of Degraded, which the spec maps to 200. The old value made /health/active answer 200 on every node, so it could not distinguish active from standby over HTTP at all — and OtOpcUa's Traefik routes the admin UI by exactly this probe, so its leader-pinning had silently never worked (lmxopcua#494). 0.3.0 (2026-07-24) finished that fix and ENDED a family-wide duplication. 0.2.1 made the tier a real 503 but still selected by RoleLeader; on a rebuilt rig the two orderings diverge live (akka leader central-1, oldest admin central-2 — the singleton host), so it would have pinned Traefik to the node not hosting the work. Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered. They agree on a freshly-formed cluster — which is why single-node and happy-path tests never caught it — and diverge permanently after any restart. Both Akka apps had already discovered this independently and each written a private replacement rather than use the shared check (ScadaBridge OldestNodeActiveHealthCheck, OtOpcUa ClusterPrimaryHealthCheck), so the package's entire active-node surface had zero consumers — not unused but avoided, twice, for the same reason. 0.3.0 promotes those two into one ClusterActiveNode primitive (oldest Up member, optional role scope, plus an ordered role-preference so a fused node answers for the role its singletons are pinned to); ActiveNodeHealthCheck and AkkaActiveNodeGate both delegate to it, and results now carry activeRole/selfAddress/activeNode in the 0.2.0 data object so a standby reports who IS active. Two further behaviour changes: a node holding none of the preferred roles is now Unhealthy, not Healthy — the old "not applicable ⇒ Healthy" is exactly what made the tier answer 200 everywhere, and the case is reachable (OtOpcUa's RoleParser admits dev-only and cluster-only nodes); and identity compares UniqueAddress, so a node restarted on the same host:port is correctly a different member. Both apps migrated and their private copies deleted — OtOpcUa (feat/health-0.3.0-active-node @ 88adec04) also routes RedundancyStateActor.SelectOldestUpMemberOfRole through the shared primitive, so the tier and the OPC UA ServiceLevel 250/240 split cannot drift; ScadaBridge (feat/site-node-health @ 5d4853a1) routes ActiveNodeEvaluator through it, unifying the S&F delivery gate, the heartbeat IsActive stamp, the inbound-API gate and the health tier on one rule. ScadaBridge keeps SitePairActiveNodeHealthCheck deliberately — it is a thin adapter over the site's own role-scoped IClusterNodeProvider (itself now backed by the shared rule), not a second copy of it. mxgw/HistorianGateway have no Akka cluster and are unaffected. Both app branches are MERGED and PUSHED (verified 2026-08-07): OtOpcUa 88adec04 is on origin/master; ScadaBridge merged via b3dc17a5 (2026-07-24) on origin/main, private-copy deletions included. |
Shared ZB.MOM.WW.Health lib |
components/health/ |
ZB.MOM.WW.Health/ |
| Observability (metrics / traces / logs) | Adopted (libs 0.1.0, on the feed; all 4 apps call AddZbTelemetry; AddZbSerilog in all but ScadaBridge, which keeps its own factory by design) |
Shared ZB.MOM.WW.Telemetry lib + .Serilog |
components/observability/ |
ZB.MOM.WW.Telemetry/ |
| Config + validation (options / startup validation) | Adopted (lib 0.1.0, on the feed; all 4 apps, pushed — the "local only / not yet pushed" caveat was stale) |
Shared ZB.MOM.WW.Configuration lib |
components/configuration/ |
ZB.MOM.WW.Configuration/ |
| Audit (event model + writer seam) | Adopted (lib 0.1.0, on the feed; all 4 apps, pushed to origin) |
Shared ZB.MOM.WW.Audit lib |
components/audit/ |
ZB.MOM.WW.Audit/ |
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Adopted (feed 0.1.0 + 0.2.0; consumed at 0.2.0 by HistorianGateway and mxaccessgw, whose Server wires AddZbGalaxyRepository — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) |
Shared ZB.MOM.WW.GalaxyRepository lib |
(design in histsdk + design doc 2026-06-23) | ZB.MOM.WW.GalaxyRepository/ |
Secrets (encrypted store + ${secret:} resolution) |
Built (current lib version 0.5.1, published to the Gitea feed — the 0.1.x→0.5.1 progression is narrated below; ScadaBridge pins 0.5.1 (it consumes the new Grpc replicator), the other three apps still pin the 0.2.3 libraries, and the 0.3.0 interactive-console delta ships in the CLI tool; HistorianGateway adopted + live-proven 2026-07-16; mxaccessgw adopted G-4/G-5/G-6 + merged to origin/main @ e088dfa 2026-07-16, box-verified fail-closed + encrypt-at-rest; ScadaBridge adopted G-3/G-4/G-5/G-6 + merged to origin/main @ 128f1596 2026-07-16, G-3 live-proven vs the real production MxGateway gateway wonder-app-vd03:5120 (secret-ref ApiKey → Connected + browsed real Galaxy); OtOpcUa adopted G-2/G-4/G-5/G-6 + merged to origin/master @ 872cf7e3 (lmxopcua) 2026-07-16 — the last app, so all four now adopted; Layer-B driver secrets (Galaxy API key + OpcUaClient Password/UserCertificatePassword) resolve fail-closed at driver session-open, AddZbSecrets registered unconditionally so driver-only nodes work; G-2 live-proven vs the real production MxGateway wonder-app-vd03:5120 — a secret:-ref Galaxy ApiKey resolved through OtOpcUa's real GalaxyDriverBrowser → dummy key MxGatewayAuthenticationException, real key → browsed the real Galaxy root (10 nodes); temp key minted+revoked+deleted); G-8 KEK-rotation BUILT (lib bumped 0.1.2→0.1.3, 2026-07-17): Rewrap DEK primitive + CAS-guarded ApplyRewrapAsync + KekRotationService.RewrapAllAsync + secret rewrap-all CLI + operator runbook (ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md), full suite green + CLI smoke + adversarial crypto review PASS (TOCTOU closed via compare-and-swap). G-7 clustered replication BUILT 2026-07-18 (lib 0.2.0) — BOTH fork options, packaged as two new libs: ZB.MOM.WW.Secrets.Replicator.SqlServer (shared SQL-Server ISecretStore = the plan's Option A, plus a local-store-with-hub mode) and ZB.MOM.WW.Secrets.Replicator.AkkaDotNet (peer-to-peer over distributed pub/sub = Option B, which the plan had deferred). Core gained ISecretsStoreMigrator, SecretLastWriterWins (one shared LWW tie-break so stores cannot diverge), SecretReplicationReconciler, and ReplicatingSecretStore — the last of which fixes a real gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was inert. Verified 170 offline / 184 with the live SQL suite / 1 skip / 0 warnings, including 15 live tests vs a real SQL Server 2022 (the SQLite suite ported case-for-case) and a 9-test in-process 2-node Akka cluster (real remoting; write→peer, delete propagation, both anti-entropy directions, late-joiner catch-up). Hard constraints across all topologies: same KEK on every node, ciphertext-only across trust boundaries, and re-wraps deliberately do NOT replicate (rewrap-all runs once per independent store). Operator runbook ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md. Corrected 2026-07-18: G-8 + the G-7 plan ARE committed and pushed on main (d82d345) — the "not yet committed/pushed" note was stale. 0.1.3 PUBLISHED to the feed 2026-07-18 (restore-verified), carrying a transitive security pin (SQLitePCLRaw.lib.e_sqlite3 → 2.1.12, advisory GHSA-2m69-gcr7-jv3q) that 0.1.2 lacked. All 4 apps bumped to 0.2.1 2026-07-18 (local feat/secrets-0.2.0 branches, NOT pushed). 0.2.1 fixes a critical defect: 0.2.0's Akka replicator was silently inert (TryAdd registration order — it published into a no-op sink and never spawned an actor). SQL-Server replication was never affected. Clustered topology is wired but default-OFF and NOT live-validated — OtOpcUa on Akka peer-to-peer, ScadaBridge on SQL-hub mode (its central and site clusters are separate Akka clusters, so pub/sub cannot cross them). Separately, a live SQLitePCLRaw vulnerability was found and fixed in ScadaBridge, where a NuGetAuditSuppress had been masking vulnerable 2.1.11 in 15 projects. 0.2.0 PUBLISHED to the feed 2026-07-18 (5 packages, restore-verified from a scratch consumer; merged to main @ 15ef1f3) — no app has adopted a clustered topology yet. 0.2.2 (2026-07-18, scadaproj#1 FIXED): the Akka replicator's hosted-process DI deadlock root-caused — a circular singleton dependency hidden in factory lambdas (ISecretStore decorator → ISecretReplicator → actor provider → ISecretCacheInvalidator → DefaultSecretResolver → ISecretStore), which MS.DI's StackGuard turns into a silent cross-thread call-site-lock deadlock instead of a stack overflow; NOT DistributedPubSub/HOCON (both disproven — the actor was never constructed). Fixed by deferring the one cycle edge (DeferredSecretCacheInvalidator, resolves on first eviction); Akka live gate now 6/6 (was: hang), regression coverage HostedProcessResolutionTests (container-built graph + hosted services; red on 0.2.1), suite 180/0/15. Fourth defect class only visible with the DI graph built in a real host. 0.2.2 PUBLISHED to the feed 2026-07-18 (5 packages, restore-verified from a scratch consumer). OtOpcUa bumped to 0.2.2 same day (lmxopcua master @ 2254ae3d): Akka gate re-run vs the FEED packages 6/6, plus a new provider-based The_startup_hook_actually_creates_the_replication_actor test of OtOpcUa's own wiring (8/8); ENABLED + live-proven on the OtOpcUa docker-dev rig same day (lmxopcua master @ c878fbbd, #482 CLOSED): all six rig nodes on Secrets:Replication:Enabled=true with one shared dev KEK; seeded row on site-a-1 alone converged to all five peers ≤60 s, byte-identical ciphertext on all six + decrypt-verified; site-node restart clean. Flag remains default-false in the product; production topologies DECIDED 2026-08-07 (scadaproj#3, design docs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md): OtOpcUa = Akka pair-local (post-Phase-6 meshes; seed each pair) with one fleet-wide production KEK via appsettings/env, never committed; ScadaBridge = a new ZB.MOM.WW.Secrets.Replicator.Grpc central-hosted hub (pull-only — updates originate at central only, by design), because the SqlServer hub's site-side connection string violates ScadaBridge's sites-talk-to-central-not-the-DB rule — SQL-hub mode stays shelved. That program is now COMPLETE and scadaproj#3 is CLOSED (2026-08-07). The Grpc replicator is BUILT and PUBLISHED — family 0.4.0 (scadaproj main @ 321ab22) then 0.4.1 (c86cead, adding a rate-limited denial Warning the live gate asked for), 6 packages on the feed, restore-verified; pull-only is enforced at the contract (secrets_hub.v1 has exactly two RPCs, GetManifest/GetSecrets, and no write RPC exists to call — pinned by a descriptor-reflection test plus a ScadaBridge route pin), auth is fail-closed constant-time bearer with uniform denial and a GetSecrets batch cap, and the site-side sweep is never-throw with per-row parse isolation so offline = last-known-good. ScadaBridge is wired both ends and live-gated PASS 4/4 (ScadaBridge main @ f6c3f7c5, merged + pushed; gate ScadaBridge/docs/plans/2026-08-07-secrets-grpc-hub-live-gate.md): the node role is a method parameter (`SecretsNodeRole.Central |
Site) so config cannot flip a site into hosting the hub, mode key Secrets:Replication:Mode ∈ {SqlServer(default, legacy-equivalent),Grpc}, hub mapped on central's existing h2c control-plane listener (CentralGrpcPort, default 8083, no new port), fail-closed startup on a missing bearer token or endpoint, 31 DI/endpoint pin tests; the gate showed 5 s convergence byte-identical and decrypt-verified on both site-a nodes, cold boot with central down serving last-known-good then resuming, tombstones ≤9 s with no resurrection, and uniform auth denial with zero credential material in any log. **OtOpcUa's pair-local topology is live-gated PASS 4/4** (lmxopcua master @ 1bd79c8e, gate OtOpcUa/docs/plans/2026-08-07-secrets-pair-local-live-gate.md): pair convergence ≤7 s, intra-pair delete <1 s, clean pair restart, and **no cross-pair propagation proven with seeded isolation controls on the other pairs** rather than by mere absence — superseding the 2026-07-18 six-node gate, which predates the mesh Phase 6 partition. **Both products remain default-off; production enablement is a separate operator action.** **[scadaproj#4](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/4) is CLOSED 2026-08-07** (plan docs/plans/2026-08-07-secrets-central-shared-sql-store.md) — ScadaBridge's central pair no longer diverges because it structurally cannot: in Grpc mode both central nodes' ISecretStore is **ONE shared SQL-Server store** (AddZbSecretsSqlServerStore; central now REQUIRES Secrets:SqlServer:ConnectionString, fail-closed — blank refused naming the key, ${secret:} refused as bootstrap circularity — and the Layer-A pre-host expander follows the store swap), so both hubs serve identical manifests by construction and the live-observed empty-manifest-from-central-b defect is unreproducible. The site client gained ordered **FallbackEndpoints** with sticky preference (family 0.5.0, main @ 7a2980e→e62b4a5): list the second central only — safe ONLY because both centrals serve one shared store (failover between independent stores is exactly the silent convergence stop #4 recorded). **Live-gated PASS 5/5 + re-drill** (ScadaBridge/docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md): central-pair parity byte-identical (sha256-matched hub responses, 1 SQL row); failover both directions (18 s convergence with central-a down, recovered-primary wrap in one sweep tick, sticky/no flapping); the #4 delete-while-follower-offline residual **GATED** (tombstone on the returning node's first sweep, no resurrection); expander proven reading the SHARED store by three-way value discrimination against a stale-SQLite decoy; fail-closed negatives (boot refusal naming the key from the expander block; SQL down → hub **errors, never an empty manifest**; sites keep last-known-good). The gate found one library defect, fixed as **0.5.1** (main @ 31ca940): the boot migrator lost the virgin-DB concurrent bring-up race (error 2714 crashed a central when the pair first-deployed simultaneously against an empty DB — the retry filter only caught 1205); now retries 1205/2714/1913/2627, re-drilled clean (both centrals booting in the same second against a virgin DB), so **0.5.1 is the minimum version for central pairs**. **Family is now 0.6.2** (2026-08-11, main @ c5e894d; 0.6.0 944595a, 0.6.1 cbfe296 — all three published and restore-verified). **0.6.2is message-only**: the under-content-root rejection now teaches the container case and states that no override exists, so nobody hunts for a flag that was deliberately not added. **0.6.1 consumers need not re-pin for it**; ScadaBridge, which has not landed yet, targets 0.6.2 directly. — **fail-closed validation of the store path, and the default moves.**Secrets:SqlitePath is now validated at startup (ValidateOnStart) by SecretsOptionsValidatorusing two rules in the new publicSecretsStorePathRules, and **neither implies the other**: it must be **absolute** (a relative value resolves against the process working directory — C:\Windows\System32for a Windows service; the source tree in a dev loop, where mxgw was observed writing its store intosrc/and tripping that repo's tree-hygiene test) **and not inside the content root** (an *absolute* path can still point into the directory every deployment here renames on upgrade — the 2026-08-09 mxgw incident, where…\MxGateway\Server\gateway-auth.dbleft withServer` and an empty store was created at the same path, so no gRPC consumer could authenticate for two days, silently). The rules reject rather than relocate, and are deliberately not exempted in Development — a guard that never runs in a dev loop or CI rots, and the config behind the outage looked entirely reasonable. BREAKING for configuration, not API: SecretsOptions.SqlitePath now defaults to SecretsOptions.DefaultSqlitePath (absolute, ZB.MOM.WW/secrets.db under LocalApplicationData), so an app configuring a relative path will not boot — remove the key or set an absolute one. Per-user, not CommonApplicationData, on purpose: that is /usr/share on macOS/Linux, which a normal user cannot write, so a machine-wide library default would fail every app's dev boot on the family's primary dev machine. The migration edge was closed by evidence before the default moved — a host relying on the OLD default would look somewhere new, find nothing, and create an empty store, i.e. this bug re-introduced by its own fix; all five deployed instances on wonder were checked and every one already sets an absolute path outside its content root (mxgw + OtOpcUa pinned in service env, which survives a missed config-preservation step; ScadaBridge central/site and HistorianGateway in config overlays, which do not). Also: AddZbSecrets validator registration is now idempotent — TryAddEnumerable de-duplicates on implementation type, which a factory descriptor does not carry, so it threw on the second call rather than skipping (mxgw calls AddZbSecrets twice; the Akka replicator calls it internally). 500 tests pass. Rollout status: pre-bump pins were ScadaBridge 0.5.1, the other three 0.2.3 — so three of the four are four-minor jumps, not re-pins. mxgw MERGED + PUSHED 0.6.0 (mxaccessgw origin/main @ 9bc70d1, three commits: the session health check, the config path rule, the re-pin; build 0/0, suite 890/890) — but 0.6.0 carries the hole and mxgw has the affected shape, so it needs a 0.6.1 follow-up. HistorianGateway has adopted 0.6.2 (4-arg content-root call site included) and re-verified on the exact probe that found the defect (in-content-root path → aborts with no .db/-wal/-shm left behind); merged to main + pushed 2026-08-13 (e42ff31). OtOpcUa has adopted 0.6.2 (b037cc7a, superseding 835e35a8; unpushed) with the heavy part discharged by live evidence rather than changelog trust: the full six-node rig booted on 0.6.1 under the new validation, a convergence smoke showed a secret written via AdminUI reaching both central nodes' independent stores, and an isolation control confirmed site-a holds only its own pair's secrets — the mesh Phase 6 posture intact. ScadaBridge is the last outstanding. OtOpcUa's jump also moves Replicator.AkkaDotNet, whose pair-local topology was live-gated on 0.2.3 — a re-gate may be the real cost there. 0.6.1 closes a hole in the 0.6.0 guard, found by the HistorianGateway adoption — target 0.6.1, never 0.6.0. The validator resolves the content root from IHostEnvironment and skipped the under-content-root rule when it was absent — but an app that expands ${secret:} references before building its host composes the subsystem in a throwaway ServiceCollection with no IHostEnvironment in it and then migrates the store from that container. So pre-host an absolute in-content-root path passed the only rule still applying (rooted), the migrator created the database, and the boot failed a second later once the real host registered IHostEnvironment. Net effect: a rejected boot that still left an empty store with -wal/-shm at the rejected path — the exact artifact that made the MxGateway outage read as the database is there, it's just empty. A relative path was unaffected (the rooted rule fires either way), and that asymmetry is what exposed it. The mistake was conflating no IHostEnvironment registered with no content root exists. Fix is an additive overload AddZbSecrets(services, config, sectionPath, contentRootPath); the 3-arg overload is unchanged. THE PIN ALONE DOES NOT CLOSE IT — the 4-arg call site is the fix. The exposure is pre-host-expander-shaped, not app-shaped: an app with a correctly configured path shows no symptom and is still unprotected, because the guard is not running at the moment the store is created — so a green re-pin reads as done while the gap is untouched. mxgw and HistorianGateway are CONFIRMED affected (mxgw GatewayApplication.cs:82, HG Program.cs:90 — both new ServiceCollection().AddZbSecrets(...) followed by SqliteSecretsStoreMigrator.MigrateAsync); OtOpcUa is CONFIRMED affected too (Program.cs, byte-for-byte the same shape) and has adopted 0.6.1; ScadaBridge is expected to be. Containers: the rule deliberately over-rejects. A store on a volume mounted INSIDE the content root (/app/data/… with /app the content root) is durable across container replacement but still rejected — in a container the image is replaced on upgrade while the volume is not, so "inside the content root" is a weaker proxy for "destroyed on upgrade" there. Not relaxed and no opt-out flag — the OtOpcUa session, which owns the only real containerized deployment here and hit the false positive, argued for keeping it strict on three grounds worth reusing: the workaround is an improvement rather than a concession (it separates the application from its durable state, the exact distinction the incident blurred); an override flag has the lifecycle of a knob set once to clear a symptom and never revisited, surviving the redeploy it was reasoned against; and mount detection would make the verdict depend on runtime platform state, so the same path would pass on one host and fail on another — strictly worse than a stable false positive with a cheap documented fix. Workaround is a second mount of the same volume outside the content root (the OtOpcUa rig used /data); the file sits at the volume root either way, so seeded stores carry over. NU1102 after a bump is a cached-index artifact, not the feed — and it recurs on every publish, consistently stranding Replicator.AkkaDotNet at the previous version while its siblings resolve (each package carries its own cached registration index), which reads even more like a feed problem than the all-packages case. Seen three times; two independent scratch restores did not reproduce it. dotnet nuget locals http-cache --clear. Treat it as expected post-publish behaviour rather than diagnosing the feed. Both affected repos put a do-not-simplify-back-to-the-3-arg-overload comment on the call site, because it still compiles and silently restores the gap — the regression arrives disguised as removing an unnecessary argument. Verify with positive controls, not a clean boot — a clean boot is equally consistent with the validator never being wired, and probing the rejected paths is what found this. Upgrade note applying to both versions: a store seeded under the old relative default is orphaned, not migrated, and nothing signals it — deleting the key, rather than leaving a relative value, is what makes the new default apply. ZB.MOM.WW.Secrets now depends on ZB.MOM.WW.Configuration 0.1.0 for the shared OptionsValidatorBase/ValidationBuilder vocabulary. Secrets.Ui is byte-identical between 0.2.3 and 0.6.0 (zero commits since 2026-07-19; both assemblies 39,424 B differing only in version stamps), so the scadaproj#2 delete-modal browser gate still covers it — the version moved only because this family shares one version across every package. Prior: ScadaBridge pinned 0.5.1 (main 68f812ea→4d7f09d5→e697477c, pushed; 472 offline tests). Rewrap fan-out on ScadaBridge collapses to ONE run against central's shared SQL store (the CLI auto-targets it via central's appsettings) + both nodes of every site pair — the "both central nodes" caveat is deleted from the runbook. The denial-warning alerting note stands (the Warning is rate-limited; alerting must sum per-window deltas). The gate's residual, ScadaBridge#34, is FIXED+CLOSED 2026-08-08 (ScadaBridge main @ e9c412e5): the pre-existing container wedge on any unhandled boot exception (crash banner then ~100% CPU main-thread spin, container stays running, restart: unless-stopped never fires) was root-caused to dotnet running as container PID 1 — Linux ignores the SIGABRT the runtime's crash path raises against PID 1, so abort() could never kill the process; not specific to any exception type or boot stage. Fixed two-layer: an AppDomain.UnhandledException handler registered before anything can throw exits 134 via the exit() syscall (covers every thread, inert under WebApplicationFactory so designed boot-refusal exceptions still propagate to tests), plus init: true on all 8 compose nodes for crash paths that bypass the managed event (Environment.FailFast, runtime-internal aborts). Live-verified: the deterministic wedge repro now exits 134 with RestartCount climbing under unless-stopped, trace intact, proven with and without init; rig redeployed healthy with docker-init as PID 1. The CoordinatedShutdown no-Environment.Exit source guard carries a precise carve-out (exactly one call, only inside the handler — which still fires Akka's run-by-clr-shutdown-hook). Operator note: production overlays running this image under their own compose should also add init: true. Side-find lmxopcua#483 FIXED+CLOSED 2026-07-19 (lmxopcua @ 5f72ff85): OtOpcUa's static per-page-rendermode router made the routed Secrets.Ui RCL page a dead SSR page; fixed with a host-side wrapper page carrying @rendermode InteractiveServer (RCL unchanged — the other three apps' globally-interactive routers would throw on a nested render mode), pin tests + Playwright-verified live. Family-wide /admin/secrets sweep 2026-07-19: ScadaBridge's page had a DIFFERENT defect — Secrets.Ui injects the shared ZB.MOM.WW.Audit.IAuditWriter seam, which ScadaBridge never registered (it has its own deliberately-distinct Commons IAuditWriter) → hard 500 on every render, hidden behind the login wall. FIXED+CLOSED as ScadaBridge#22 (ScadaBridge main @ fc86e8bf): CentralSharedSeamAuditWriter bridges the shared seam to ICentralAuditWriter (central direct-write → dbo.AuditLog, NOT the site SQLite chain), pinned by red-first WebApplicationFactory DI tests over the real Program.cs, live-proven on the local docker cluster (page interactive; secret.add/secret.delete audit rows landed in dbo.AuditLog). The sweep also surfaced a Secrets.Ui library defect, scadaproj#2 — FIXED+CLOSED same day via 0.2.3 (5 packages PUBLISHED to the feed; scadaproj main @ 62ab8f6): ConfirmDeleteModal used bare class="modal" and the RCL shipped no CSS, so Bootstrap 5's .modal{display:none} made the delete modal permanently invisible on every Bootstrap host (confirmed live on ScadaBridge AND OtOpcUa; HG links the same bundle; all four apps are Bootstrap hosts — the earlier claim that mxgw was Bootstrap-free was false, corrected 2026-08-11: src/ZB.MOM.WW.MxGateway.Server/libman.json pins bootstrap@5.3.3 into wwwroot/lib/bootstrap/ and App.razor:7 links it ahead of <ThemeHead />. What mxgw's CLAUDE.md forbids is Blazor component libraries (MudBlazor/Radzen/FluentUI), not Bootstrap CSS/JS) — handlers worked, only CSS hid it. Fix entirely in the RCL: zb-secrets-modal* class rename + a component-emitted <style> block (Theme tokens w/ fallbacks; testids unchanged; bUnit pins). Scoped .razor.css was deliberately rejected — only ScadaBridge linked a scoped-CSS bundle at the time (2026-07-19), so isolation CSS would silently not load on the other 3 hosts — still the right call, and note the premise has since changed: OtOpcUa's 2026-08-10 AdminUI sweep linked its own dead bundle (App.razor:23), so as of 2026-08-11 two of the four link one, while mxgw and HistorianGateway have zero *.razor.css and emit no bundle at all (the same silent-failure class). Live-verified on ScadaBridge (bumped to 0.2.3 @ 63510778): modal visible display=flex with zero JS forcing, delete flows through the real path. Rollout COMPLETE 2026-07-19 — all four apps on 0.2.3, pushed: OtOpcUa (lmxopcua master @ be87ddeb; AdminUI 665/665 + secrets integration 8/8; docker-dev rig rebuilt, modal gate PASS live), HistorianGateway (main @ 7452eb4, from 0.2.0; plus 6bc005d fixing the pre-existing AngleSharp NU1902 warn-as-error that had made the whole HG test suite unbuildable — test-only pin to 1.5.2, suite now runs: 854 pass / 1 pre-existing VPN-gated live failure, histsdk failures identical to the pre-bump baseline; docker container rebuilt, modal gate PASS live), and mxgw (main @ 8f7ee49, from 0.2.0; 780 pass / 45 macOS-only NamedPipe-harness failures identical before and after the bump — zero delta). mxgw's modal gate was originally SKIPPED on the false "Bootstrap-free so never affected" reasoning, and is now PASS (2026-08-11) — run live against a local rig: modal root class is zb-secrets-modal, document.querySelector('.modal') is null so Bootstrap's .modal{display:none} has nothing to bite, computed display:flex / position:fixed / z-index 1081 over a full-viewport 1080 backdrop, and elementFromPoint at the confirm button's centre returns the button rather than the backdrop (visible ≠ hit-testable — that probe is the one that proves usable). Cancel disarmed cleanly with no delete performed. The exemption reached the right conclusion by luck: mxgw was a Bootstrap host and was affected, but had already taken the 0.2.3 fix independently of the sweep. Lesson: an exemption from a family-wide gate must re-derive the exempting property from the repo at gate time — see admin_ui_cleanup.md 0.3.0 (2026-07-19, merged to main @ 87ffe1b) added a menu-driven Spectre.Console interactive operator console to the secret CLI: bare secret with no verb on a real TTY (redirected I/O or any verb → unchanged headless behavior, no-args-redirected → usage/exit-2) opens a guided session that targets a deployment's store by reading the target app's own appsettings.json (base + env overlay + env vars → Secrets section, so it hits exactly the store the app reads; recents cached at ~/.zb-secrets/recent-targets.json, names/paths only). Eight flows: list/set/reveal/delete plus the two headline ones — Reference audit & seed (classifies every ${secret:NAME} ref Ok/PresentUnverified/Missing/Tombstoned/Undecryptable/InvalidName, then seeds the gaps — deployment setup) and KEK doctor (per-row Ok/WrongKek/Corrupt verdicts + guided rewrap-all or lost-KEK re-set — lockout recovery), plus ciphertext-only bundle export/import. Degraded (no-KEK) sessions open metadata-only and upgrade in place via masked paste/key-file; no login (local trust); key material never a literal arg. 5 packages published at 0.3.0 to the Gitea feed (restore-verified); the four apps still pin the 0.2.3 libraries (the console ships in the CLI tool, not the app packages). Runbook ZB.MOM.WW.Secrets/docs/operations/interactive-console.md + quickstart ZB.MOM.WW.Secrets/docs/interactive-console-quickstart.md. |
Shared ZB.MOM.WW.Secrets lib (5 packages + CLI) |
components/secrets/ |
| LocalDb (embedded cache + 2-node sync) | Adopted by ScadaBridge — Phases 1 AND 2 COMPLETE and MERGED to main (2026-07-20, PR #23, merge 28ca04d7, pushed to origin; both feat/localdb-phase* branches fully contained and kept only as history). Libs 0.3.0 on the Gitea feed (3 pkgs; 0.1.0–0.2.1 also present). 0.3.0 (2026-08-15) makes the snapshot-resync flag PER-TABLE (branch localdb-per-table-snapshot; the residual 0.2.1 explicitly deferred). On-disk bookkeeping schema goes to v3 — a new __localdb_snapshot_state table, upgraded in place on open (the transition is a version stamp only: the shared DDL creates the table with IF NOT EXISTS on every open, and empty is the correct v3 state for a file that never baselined). Wire-compatible with 0.1.x/0.2.x, and lib_schema_version deliberately STAYS 1 because ValidateHandshake compares it fail-closed for equality — bumping it would stop mixed pairs syncing outright, so the extension is negotiated by capability: Handshake.supports_partial_snapshot (new field 5) and SnapshotBegin.tables (new field 2, where empty == all tables, which is exactly what a pre-0.3.0 sender emits and a pre-0.3.0 receiver assumes). RegisterReplicated(table, baselineExistingRows: true) now flags only that table, so a snapshot streams only the flagged tables; the database-wide __localdb_peer_state.needs_snapshot survives unchanged for the cases that genuinely reach every table (oplog cap prune, impossible peer ack). A peer that does not advertise the capability is served a FULL snapshot — a scoped one would make it advance last_applied_remote_seq to the snapshot's as-of and silently discard every delta below it for the tables left out. For the same reason a partial snapshot moves no watermark on either side: the receiver keeps last_applied_remote_seq and the sender keeps _sentThruSeq, so the overlapping deltas are still pumped and merged by LWW. Crash safety: snapshot debt now clears on the receiver's new SnapshotAck (exchanged only between 0.3.0 peers) rather than on enqueue, so an ungraceful exit mid-snapshot re-streams the same scope on reconnect; against a pre-0.3.0 peer the 0.2.x clear-on-enqueue is kept, there being no ack to wait for. Composes with 0.2.1's bounded 64-message inbox and out-of-handshake snapshot (the ack rides the unbounded CONTROL lane, like DeltaAck). 199/199 tests, including a single-table-baseline scope test, a mixed-version interop test (old peer ⇒ full snapshot), an ack-gated-debt test and a v2→v3 upgrade test. ScadaBridge pins 0.3.0; Host/SiteLocalDbSetup.cs needed no restructuring — it already calls RegisterReplicated once per table, which is what per-table flagging keys off, so adding an eleventh replicated table to an already-replicating site now streams that one table instead of all eleven. 0.2.1 (2026-08-14) closes two adversarial-review findings against 0.2.0 (same branch localdb-perf-remediation; wire-compatible, no proto/lib_schema_version/on-disk-schema change): DeregisterReplicated now flushes the HLC's durable crash anchor inside its own transaction, before the prune — clock recovery is MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc) and hlc_high_water was written only by Dispose, so on a file whose tables had all been deregistered an ungraceful exit left the clock nothing to recover from and it could re-issue stamps below HLCs peers already held (LWW then discards them as older); and the gRPC sync inbox is bounded at 64 messages with wait-mode backpressure instead of unbounded, capping inbound memory at ~64 × MaxBatchBytes rather than "whatever the peer sends" — the bad case being a mutual full snapshot (the snapshot flag is per-DATABASE and both sides of a late-enabled pair baseline unconditionally), previously buffered whole in memory on both nodes at once. Bounding required moving the snapshot a node owes out of the handshake to run alongside a live receive loop; the old ordering left neither side draining its inbox while sending and deadlocks under a bounded transport (pinned by a capacity-1 mutual-snapshot test). Per-table needs_snapshot was considered and deferred at 0.2.1 — it needs an on-disk schema change; the cost was instead documented (trim high-volume tables like event logs before enabling replication late). That residual is closed by 0.3.0, above. 188/188 tests at 0.2.1. 0.2.0 (2026-08-14) is the performance + lifecycle release from the ScadaBridge architectural review (WP3.3, branch localdb-perf-remediation) — wire-compatible with 0.1.x peers (no proto change, no lib_schema_version bump); the on-disk bookkeeping schema goes to v2, upgraded in place on open. Six changes: DeregisterReplicated(table) drops a table's capture triggers and prunes its oplog + row-version rows (idempotent, and deliberately usable on triggers left by an EARLIER build — there was previously no way to stop capturing once a file had been registered); RegisterReplicated(table, baselineExistingRows: true) closes the late-opt-in hole, seeding the row-version ledger for pre-existing rows at the LWW floor (HLC 0, this node's id) and flagging a snapshot resync, because the snapshot streamer pages from that ledger and CDC never wrote pre-registration rows there — a node that started replicating LATE never sent them, silently and forever (the two APIs are a designed pair: the ledger prune is safe only because re-registration can re-baseline; deregistration must still be symmetric, which the fail-closed registered-table digest handshake enforces loudly); byte-budget batching via MaxBatchBytes (default 2 MB) with MaxBatchSize demoted to a secondary row cap, plus inbound delta/ack coalescing; O(1) backlog depth (MAX(seq) − MAX(last_acked_seq, MIN(seq) − 1)) replacing a COUNT(*) over the whole backlog that ran per maintenance tick AND per metrics scrape; LwwApplier prepare-once-per-statement; and the unused __localdb_oplog_hlc index dropped. 0.1.2 (2026-07-21) fixes snapshot resync for a WIPED peer: a converged pair prunes every oplog row on ack, and the handshake read an empty oplog as "no gap possible" — so the steady state of a healthy pair was the one state from which a rebuilt node (lost volume, re-imaged host) could never be back-filled; it came back empty and stayed empty until the next deploy. The gap is now measured against last_acked_seq when the oplog is empty. Found by the OtOpcUa Phase 1 live gate (check 4) as a documented limitation, then closed. 0.1.3 (2026-07-21) closes the other half, which only became REACHABLE once back-fill worked: the rebuilt peer's OWN writes were silently dropped. last_applied_remote_seq is a watermark in the peer's seq space and a rebuilt peer numbers from 1, so the healthy node's stale watermark made the sending side skip its whole oplog — the pair looked converged right up until the moment only the rebuilt node applied a deploy. Fixed by resetting the inbound watermark on a peer-node-id change (NOT last_acked_seq — that is what 0.1.2 keys off) plus rejecting a peer claim beyond anything we ever produced. Live-gated on the OtOpcUa docker-dev rig: OtOpcUa/docs/plans/2026-07-21-localdb-followups-live-gate.md. 0.1.1 fixes a latent boot defect: the library never created the parent directory of LocalDb:Path and SqliteLocalDb opens the file eagerly, so a missing directory was a hard boot failure (SQLite Error 14) — routine given the relative ./data/… defaults, and masked on docker only by the volume mount. ScadaBridge had worked around it app-side; the fix moved into the library and the shim is deleted. Phase 1 (merged in PR #23): OperationTracking + site_events consolidated into one LocalDb:Path database, fail-closed bearer interceptor on the sync endpoint, ISyncStatus health bridge, one-time legacy migrator. Phase 2 (merged in the same PR): adds sf_messages + 7 site config tables (10 replicated in total) and deletes ScadaBridge's bespoke SiteReplicationActor, its messages, StoreAndForward's ReplicationService, and ReplaceAllAsync — including the whole notify-and-fetch exchange, so a standby now makes NO config fetch on deploy (SiteReconciliationActor's startup fetch survives; different path). notification_lists/smtp_configurations are created but deliberately NEVER registered — permanently empty, and a channel for them would carry plaintext SMTP passwords. Live gate PASS, all 10 checks (ScadaBridge/docs/plans/2026-07-19-localdb-phase2-live-gate.md): byte-identical config rows with identical HLC + origin node id, standby zero-fetch, 3-table cascade delete converging with tombstones on both nodes, site failover with zero duplicate ids, clean both-nodes-together restart. Replication remains default-OFF, enabled on the docker rig's site-a pair only (site-b/site-c unreplicated as the default-OFF pin); no production deployment has it enabled. Operational constraints: a site pair must be stopped/started TOGETHER (rolling upgrade unsupported since the SfBufferSnapshot compat handler was deleted), a node offline beyond TombstoneRetention (7 d) can resurrect deleted rows, and — before 0.2.0 — MaxBatchSize batched by ROW COUNT against a 4 MB gRPC cap, which is why the rig pinned it to 16 (the 500 default would allow ~35 MB). That pin is retired: ScadaBridge consumed 0.2.0 on 2026-08-14 (branch arch-review-remediation), removed MaxBatchSize from both docker/site-a node configs and left MaxBatchBytes at its 2 MB default. The same consumption made Host/SiteLocalDbSetup.cs self-healing in both directions: an unreplicated node (site-b/site-c) now deregisters all ten tables at boot, dropping stale triggers an earlier build installed, and a replicating node registers with baselineExistingRows: true, so turning replication ON for a site that had been running without it finally converges on the rows already in the file rather than only on writes made after the restart. Also adopted by OtOpcUa — Phase 1 COMPLETE and MERGED to master 2026-07-21 (merge c957db52), and Phase 2 (alarm store-and-forward on LocalDb) EXECUTED, live-gated and MERGED to master (merge 06b5d81b; the earlier "designed but NOT executed" claim was stale — corrected 2026-08-07; residual: live-gate checks 3+4 were never re-run on the post-mesh-Phase-6 topology): a pair-local consolidated SQLite cache of the deployed-configuration artifact (chunked, SHA-256-verified, newest-2 retention) that lets a driver node boot from cache when central SQL Server is unreachable, registered in the hasDriver branch only (admin-only nodes never register it), with the same default-OFF fail-closed replication. Its live gate passed 8/8 and caught four defects offline tests missed. Consumers: ScadaBridge (Phases 1+2) and OtOpcUa (Phases 1+2), ScadaBridge pins 0.3.0 (2026-08-15, residual R1; it consumed 0.2.0 on 2026-08-14 under arch-review WP3.3) and OtOpcUa pins 0.2.1 (2026-08-15, residual R4, merged to its master @ 5a7aae14 — a pure pin bump, no code change; it also corrected its MaxBatchSize = 16 rationale, kept at 16 as the DB read-page bound). The remaining 0.2.1-vs-0.3.0 skew is supported: every release is wire-compatible (0.3.0 negotiates its scoped snapshots by capability and falls back to a full snapshot for an older peer) and the schema v2/v3 upgrades are local-file state. Note OtOpcUa registers all four tables unconditionally, so it cannot baseline pre-existing rows on a late replication enable (ScadaBridge deferred-register row 39). |
Shared ZB.MOM.WW.LocalDb lib |
docs/plans/2026-07-17-localdb-design.md + docs/plans/2026-07-19-scadabridge-localdb-design.md + docs/plans/2026-07-20-otopcua-localdb-design.md (both adoption designs live here); ScadaBridge holds the Phase 1 plan + Phase 2 gate under its own docs/plans/, OtOpcUa its two phase plans + two live gates |
ZB.MOM.WW.LocalDb/ |
The auth component is fully populated: a normalized spec, a
proposed shared-contract, three
current-state docs, and an adoption GAPS
backlog. Common ground = LDAP/GLAuth identity + peppered-HMAC API keys; left per-project =
the authz vocabularies (OPC-UA permissions / gRPC scopes / roles + site-scoping).
The shared library is built and lives in this repo at ZB.MOM.WW.Auth/
(a plain directory of this repo — no nested .git, verified 2026-08-07; .NET 10; 4 packages — Abstractions, Ldap, ApiKeys, AspNetCore;
250 tests passing + 2 skipped (verified 2026-08-10 at 0.2.0; was 215+1 at 0.1.5); feed carries 0.1.0–0.2.0). The implementation plan is at
docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md.
Adopted across all three apps on 2026-06-02 (auth GAPS #1–#8) on each repo's feat/adopt-zb-auth branch —
committed + reviewed, then fast-forward-merged into the repo's local default (main/master) and PUSHED to origin
(gitea) on 2026-06-03 (in sync; the feat/* branches kept locally as history). Cutover: shared Auth.Ldap,
Auth.ApiKeys (ScadaBridge inbound fully re-architected to the keyId/Bearer model), IGroupRoleMapper<TRole> seam,
Transport-enum config, canonical ZbClaimTypes/ZbCookieDefaults, unified dev base DN dc=zb,dc=local, and the
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: all four apps pin 0.2.1 as of 2026-08-13 (commits listed in the table row above, all pushed to origin). Prior history — all four pinned 0.1.5 (verified 2026-08-07): MxGateway via e107019 2026-07-18 (earlier bumped 0.1.2→0.1.4 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit 197731a; also required implementing the 0.1.3 IApiKeyAdminStore.SetScopes/SetEnabled members on a test fake), ScadaBridge 50d79ed1 + HistorianGateway df211ee 2026-07-18, OtOpcUa 1adaa2fc 2026-07-20 (PR #484, family alignment from 0.1.1). Per-repo detail in components/auth/GAPS.md +
docs/plans/2026-06-02-auth-audit-normalization*.md.
Build/test from ZB.MOM.WW.Auth/: dotnet test. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
0.1.4 (2026-07-09, archreview G-2): optional ApiKeyRecord.ExpiresUtc (nullable = never-expires), ApiKeyFailure.KeyExpired, ApiKeyVerifier expiry enforcement (inclusive, injected clock), a CreateKeyAsync(expiresUtc) overload, and a SQLite schema v3 additive expires_utc column (idempotent ALTER; donor v2 DBs upgrade in place, no key invalidated). All four Auth pkgs published to the Gitea feed at 0.1.4 (2026-07-09). HistorianGateway adopted the expiry surface at 0.1.4 — apikey create --expires + dashboard Expires column (Task-10 done). 0.1.5 (2026-07-18) is 0.1.4 plus the SQLitePCLRaw.lib.e_sqlite3 2.1.12 transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). 0.2.0 (2026-08-10) — LDAP backup-DC sticky failover (plan docs/plans/2026-08-10-ldap-backup-dc-failover.md, design …-design.md; 4 pkgs published to the feed, restore-verified; family Directory.Build.props now 0.2.0): one additive LdapOptions.FallbackServers list ("host" or "host:port", empty default = failover off, single-endpoint behaviour byte-unchanged) makes LdapAuthService walk an ordered endpoint list (primary + fallbacks) on system-side failures only — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (Success/UserNotFound/AmbiguousUser/BadCredentials/GroupLookupFailed) is final and never retried elsewhere (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is sticky — the last endpoint that gave any authoritative answer (success or a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed FallbackServers entry naming the field; optional ILogger<LdapAuthService> emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via AddZbLdapAuth (GetService, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (ZB_LDAP_IT=1). No Abstractions contract change beyond the new option; no LdapAuthFailure enum change. App adoption (pin bump to 0.2.0 + authoring FallbackServers) is a follow-up.
0.2.1 (2026-08-12) — AD continuation referrals no longer fail the search (4 pkgs published to the feed, restore-verified; the assembly restored from the feed carries the fix and 0.2.0 does not — checked as a positive control, not by trusting the push). Active Directory answers any search based at a domain root with continuation references (to CN=Configuration, DC=ForestDnsZones, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing LdapReferralException from Next(). That escaped NovellLdapConnection.Search, and LdapAuthService maps a failed search to ServiceAccountBindFailed, so every login against SearchBase = DC=nam,DC=zimmer,DC=com failed system-side on both DCs while the directory, the credentials and the service account were all healthy — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new tools/LdapProbe (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (ReadEntries(ILdapSearchResults), extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. Non-referral faults still propagate — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (NovellSearchReferralTests), 6 verified red against the pre-fix code, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. No Abstractions change; no config change. Consumer impact: an app on 0.1.5 cannot authenticate against an AD domain root at all, so the pin bump is a functional fix, not housekeeping. Closed 2026-08-13: all four apps adopted 0.2.1 (see the table row for commits), and FallbackServers was live-gated the same day against the redundant local GLAuth pair (infra/glauth-redundant/).
tools/LdapProbe (ldapprobe, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the real LdapAuthService so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, nested groups via LDAP_MATCHING_RULE_IN_CHAIN, the primary group (which AD omits from memberOf, so it can never be a role group), group type (security vs distribution), decoded userAccountControl, referral counts, and the TLS chain with key sizes. --list-groups '<pat>' enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in ldap_setup.md, including the UG_WW_* Wonderware group convention (39 groups) that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
The UI-theme component is fully populated: a normalized spec,
a design-tokens reference, a
shared-contract, three
current-state docs, and an adoption GAPS
backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + widgets; left
per-project = each app's site.css page layout, route content, scoped .razor.css.
The shared RCL is built and lives in this repo at ZB.MOM.WW.Theme/
(.NET 10 Razor Class Library; single package; 48 bUnit tests (re-verified 2026-08-11 at 0.4.1); feed carries 0.2.0–0.4.1,
published to the Gitea feed). The build plan is at
docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md;
the adoption plan at docs/plans/2026-06-03-ui-theme-adoption.md.
Adopted across all three apps on 2026-06-03 (full canonical cutover, SPEC §7) on each repo's
feat/adopt-zb-theme branch — committed + spec/code-reviewed, then fast-forward-merged into each repo's local
default (master/main) and PUSHED to origin (gitea) (in sync; feat/* kept locally as history): OtOpcUa
→lmxopcua master@11de14d, ScadaBridge main@58352a6, MxGateway→mxaccessgw main@73e54e2. The 0.1.0 → 0.2.0 bump first promoted nav-expand persistence
into the kit (NavRailSection.Key/data-nav-key + a localStorage nav-state.js enhancer emitted by a new
<ThemeScripts/>), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
island retired); MxGateway additionally gained a net-new Blazor <LoginCard> /login page over its existing
hardened endpoint. Per-app result in components/ui-theme/GAPS.md.
Build/test from ZB.MOM.WW.Theme/: dotnet test. Consumer matrix: all three apps consume
the single ZB.MOM.WW.Theme package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
The health component is fully populated: a normalized spec, a
shared-contract, three
current-state docs, and an adoption GAPS
backlog. Shared = three-tier endpoint convention (ready/active/healthz) + canonical JSON writer +
IActiveNodeGate seam + GrpcDependencyHealthCheck + AkkaClusterHealthCheck + ActiveNodeHealthCheck
DatabaseHealthCheck<TContext>; left per-project = which probes each app registers, orchestrator wiring, and ScadaBridge's distributed health-monitoring pipeline.
The shared library is built and lives in this repo at ZB.MOM.WW.Health/
(.NET 10; 3 packages — ZB.MOM.WW.Health, ZB.MOM.WW.Health.Akka, ZB.MOM.WW.Health.EntityFrameworkCore;
64 tests (verified 2026-07-18); dotnet pack → 3 nupkgs @ 0.1.0).
Adopted by all four apps — verified 2026-07-18 (each references the package and wires MapZbHealth); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified — see components/health/GAPS.md.
Build/test from ZB.MOM.WW.Health/: dotnet test. Consumer matrix: MxAccessGateway → core only;
OtOpcUa & ScadaBridge → all three packages.
The observability component is fully populated: a normalized spec,
a metric-conventions reference, a
shared-contract, three
current-state docs, and an adoption GAPS
backlog. Shared = OTel Resource (service.name/site.id/node.role identity triple) + standard instrumentation
(ASP.NET Core, HttpClient, gRPC client, runtime, process) + Prometheus always-on exporter + OTLP opt-in
- Serilog two-stage bootstrap + SiteId/NodeRole/NodeHostname enrichers + TraceContextEnricher (trace_id/span_id)
- ILogRedactor seam; left per-project = application Meters/ActivitySources, sink config, per-operation enrichers, and redaction policies.
The shared library is built and lives in this repo at ZB.MOM.WW.Telemetry/
(.NET 10; 2 packages — ZB.MOM.WW.Telemetry, ZB.MOM.WW.Telemetry.Serilog; 34 tests (verified 2026-07-18);
dotnet pack → 2 nupkgs @ 0.1.0). Adopted across all three apps on 2026-06-01 (branch
feat/adopt-zb-telemetry per repo, behaviour-preserving): AddZbTelemetry (Resource + standard
instrumentation + Prometheus /metrics) everywhere; OtOpcUa + MxGateway on AddZbSerilog (MxGateway's
MEL→Serilog migration + metrics export both landed in this pass — they were not actually done
beforehand despite an earlier claim); ScadaBridge keeps its LoggerConfigurationFactory (min-level
governance) and only adds the shared TraceContextEnricher. Deferred: MxGateway ms→s + Meter
rename, ScadaBridge app instruments + Site-node HTTP/1.1 metrics listener, OTLP wiring. Per-repo
result tracked in components/observability/GAPS.md.
Build/test from ZB.MOM.WW.Telemetry/: dotnet test. Consumer matrix: all three apps consume both
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
The configuration component is fully populated: a normalized spec, a
shared-contract, three
current-state docs, and an adoption GAPS
backlog. Shared = the IValidateOptions<T> failure-accumulation base (OptionsValidatorBase<T>) +
reusable rule primitives (ValidationBuilder: port / host:port / required / positive-duration / one-of /
min-count) + AddValidatedOptions<TOptions,TValidator>() (bind + validate + ValidateOnStart) + the
pre-host ConfigPreflight aggregator (generalizes ScadaBridge's StartupValidator, byte-compatible
message); left per-project = each app's options classes + domain rules, and OtOpcUa's
draft/generation-content validation (DB-side sp_ValidateDraft; its C# DraftValidator is dormant).
The shared library is built and lives in this repo at ZB.MOM.WW.Configuration/
(.NET 10; single package ZB.MOM.WW.Configuration; 42 tests (verified 2026-07-18); dotnet pack → 1 nupkg @ 0.1.0).
The implementation plan is at
docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md.
Adopted across all three apps on 2026-06-01 (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
local default branch (main/master) — merged, not yet pushed to remotes; the package was first
published to the Gitea feed. Behaviour-preserving onto OptionsValidatorBase/AddValidatedOptions
for MxGateway + ScadaBridge (validator messages byte-identical), StartupValidator → ConfigPreflight
for ScadaBridge, and net-new Ldap/OpcUa validators for OtOpcUa. Per-app result tracked in
components/configuration/GAPS.md.
Build/test from ZB.MOM.WW.Configuration/: dotnet test. Consumer matrix: all three apps consume the
single package; ScadaBridge is the heaviest adopter (per-module validators + StartupValidator →
ConfigPreflight); OtOpcUa adoption is additive (it has no IValidateOptions usage today).
The audit component is fully populated: a normalized spec, an
event-model reference, a
shared-contract, three
current-state docs, and an adoption GAPS
backlog. Common ground = canonical AuditEvent record + AuditOutcome enum + IAuditWriter /
IAuditRedactor seams + helpers (NullAuditRedactor, TruncatingAuditRedactor, NoOpAuditWriter,
CompositeAuditWriter, RedactingAuditWriter) + AddZbAudit DI registration; left per-project =
transport/storage and domain vocabulary. Closes the loop on Auth — audit's Actor field = the Auth
principal. IAuditRedactor is aligned with Telemetry's ILogRedactor seam convention.
The shared library is built and lives in this repo at ZB.MOM.WW.Audit/
(.NET 10; 1 package — ZB.MOM.WW.Audit; only non-BCL dependency Microsoft.Extensions.DependencyInjection.Abstractions;
23 tests (verified 2026-07-18); dotnet pack → 1 nupkg @ 0.1.0). Repo: https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit.
Adopted across all three apps on 2026-06-02 (audit GAPS #1–#6) on each repo's feat/adopt-zb-audit branch
(stacked on feat/adopt-zb-auth) — committed + reviewed, then merged into the repo's local default (main/master)
and PUSHED to origin (gitea) on 2026-06-03 (in sync). Depth =
DEEP adopt (the canonical 9-field AuditEvent is the record everywhere; domain fields ride in DetailsJson).
OtOpcUa: canonical record + AuditWriterActor : IAuditWriter + Outcome column/migration + ClusterAudit fix.
MxGateway: new canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore→canonical adapter.
ScadaBridge: a full audit-subsystem re-architecture (the program's largest task) — canonical record everywhere via a
deterministic codec; site SQLite split into audit_event + an audit_forward_state forwarding sidecar; central
partitioned dbo.AuditLog collapsed to 10 canonical cols + persisted computed cols (CollapseAuditLogToCanonical
migration, MSSQL-verified). Phase 3 wires Actor from the Auth principal at authenticated emit sites (per-app
IAuditActorAccessor). Per-repo detail in components/audit/GAPS.md +
docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md + …-scadabridge-audit-rearch.md.
Build/test from ZB.MOM.WW.Audit/: dotnet test. Consumer matrix: all three apps consume the single
ZB.MOM.WW.Audit package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
The Galaxy Repository component normalizes the Galaxy object-hierarchy SQL browse + reusable gRPC service
that was previously embedded in mxaccessgw. Shared = canonical galaxy_repository.v1 proto (wire-compatible
with mxaccessgw's existing contract so OtOpcUa's GalaxyRepositoryClient is unaffected), the SQL browse
provider (HierarchySql / AttributesSql validated reverse-engineered queries), in-memory hierarchy cache +
snapshot + deploy-poll refresh BackgroundService, GalaxyHierarchyProjector, and AddZbGalaxyRepository /
MapZbGalaxyRepository DI extension. Left per-consumer = section path, subtree auth filtering, and any
app-specific paging defaults.
The shared library is built and lives in this repo at ZB.MOM.WW.GalaxyRepository/
(.NET 10; single package ZB.MOM.WW.GalaxyRepository; dotnet pack → 1 nupkg @ 0.1.0, published to
the Gitea NuGet feed gitea.dohertylan.com/api/packages/dohertj2/nuget). The design doc is at
docs/plans/2026-06-23-historian-gateway-design.md (§10, component 1).
Consumed by HistorianGateway as a PackageReference from that Gitea feed, pinned at 0.2.0 (originally a
cross-repo ProjectReference to this scadaproj tree; switched to the feed package 2026-06-24; bumped 0.1.0 → 0.2.0 2026-06-25).
mxaccessgw adoption is a tracked follow-on — once adopted, mxaccessgw's inline Galaxy browse code is replaced
by the shared lib (the galaxy_repository.v1 wire contract is already identical, so OtOpcUa and ScadaBridge
clients are unaffected). Build/test from ZB.MOM.WW.GalaxyRepository/: dotnet test.
Consumer matrix: HistorianGateway (initial); mxaccessgw (follow-on adoption).
Hosted application: ZB.MOM.WW.Overview (family dashboard)
Besides the shared libraries, this repo hosts one application:
ZB.MOM.WW.Overview/ — a Blazor Server single-pane dashboard that polls
every registered instance's anonymous /health/ready + /health/active, caches one snapshot
(pages never poll) and renders per-cluster state including the Akka leader chip, which is what
the ZB.MOM.WW.Health 0.2.0/0.3.0 per-entry data work exists to feed. No login, strictly
read-only, holds no credentials — a requirement, not an omission; it references no Auth/Audit/
Secrets package. Design + impl plan: docs/plans/2026-07-22-overview-dashboard-{design,impl-plan}.md
(ledger 19/20; the open task is Phase 4 live acceptance, recorded in 41013dc as 7 of 9 design §9
checks passing, 2 blocked on an OtOpcUa rig redeploy).
The Applications registry ships empty on purpose: a deployment supplying none fails startup
naming the key rather than booting an empty dashboard. Per-deployment overlays are committed —
appsettings.Docker.json (the local rigs, joined via external networks) and
appsettings.Wonder.json.
Deployed to wonder-app-vd03 2026-08-11 (main @ 4f97e72) as that box's fifth app:
service ZbOverview on :5320 under the shared NSSM, ASPNETCORE_ENVIRONMENT=Wonder,
5/5 instances Up. Two constraints worth carrying to any future box:
- Publish self-contained (
-r win-x64 --self-contained true). wonder has no .NET 10 runtime — every app there carries its own. An FDD publish installs fine and then fails at service start. - Registry
BaseUrlmust not put co-located instances on a shared host name. Leader-address → instance resolution matches on host only (Akka and HTTP ports differ by design), which assumes distinct hosts per instance — true on the rig, false on a single box, where the fleet-wide scan lets the first matching entry win the chip for every group. On wonder that made ScadaBridge's site card claimLeader · central, a different Akka cluster. Fixed by using127.0.0.1, which matches nothing, so each chip falls back to the raw Akka address.
QA runs an instance on EACH pair node — wonder-app-vq03:5320 and (since 2026-08-14)
wonder-app-vq04:5320 — both watching the same registry, now 10 instances across 4
applications after the 2026-08-14 VQ04 singleton clone (MxGateway + HistorianGateway added on
vq04; see deployment.md §7.1). It is the fastest way to read QA's whole topology.
Its registry uses real FQDNs, and that is correct rather than a divergence: the 127.0.0.1
rule above exists only because VD03 co-locates every instance on one host. QA is a 2-node pair
(vq03/vq04) with genuinely distinct hosts, so leader resolution works as designed and the chips
resolve to real names (node-a (vq03)). Carry the reason, not the literal 127.0.0.1.
⚠️
wonder-app-vd03is the DEV box, not production (vd= DEV,vq= QA — confirmed bymes-delmia-integration-api.md§1.1). ItsASPNETCORE_ENVIRONMENTgenuinely isProduction, but that is a .NET config-overlay name and is orthogonal to the deployment tier. No production box has been identified. Full host/port inventory for both tiers:deployment.md§2.1 + §7.1.
Per-project primary commands
Run these from inside each project directory (not from scadaproj).
# OtOpcUa
dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test ZB.MOM.WW.OtOpcUa.slnx
dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
# Docker fixtures run on shared host 10.100.0.35; control via `lmxopcua-fix` (in ~/bin)
# MxAccessGateway (PowerShell on Windows)
dotnet build src/MxGateway.sln
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86 # worker MUST be x86
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj # no MXAccess needed (fake worker)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
# ScadaBridge (~/Desktop/ScadaBridge)
dotnet build ZB.MOM.WW.ScadaBridge.slnx
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
cd infra && docker compose up -d # local test services (SQL, OPC UA, SMTP, REST, Traefik) — LDAP is NOT here
# HistorianGateway (~/Desktop/HistorianGateway)
dotnet build ZB.MOM.WW.HistorianGateway.slnx
dotnet test ZB.MOM.WW.HistorianGateway.slnx # unit + golden; live integration tests skip without env vars
dotnet run --project src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj
# Dev: dashboard on :5220 (HTTP/1.1), gRPC h2c on :5221 (from appsettings.Development.json)
# Production: single Kestrel:Endpoints:Https with Protocols=Http1AndHttp2 (ALPN, one TLS port)
# Live integration (need HISTORIAN_GRPC_HOST + HISTORIAN_GRPC_WRITE_SANDBOX_TAG + GALAXY_SQL_CONNSTR set)
dotnet test ZB.MOM.WW.HistorianGateway.slnx --filter "Category=LiveIntegration"
# Local Docker (HistorianGateway/docker/): publish FDD on the host (authed Gitea feed → no Docker restore),
# runtime-only aspnet:10.0 image; login + gRPC API-key auth disabled; points at the REAL wonder datasources.
dotnet publish src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj -c Release -o docker/publish -p:UseAppHost=false
cd docker && docker compose up -d --build # dashboard/health/metrics :5220, gRPC h2c :5221 (needs host VPN for egress)
# ZB.MOM.WW.Overview (~/Desktop/scadaproj/ZB.MOM.WW.Overview — the hosted family dashboard)
dotnet build ZB.MOM.WW.Overview.slnx
dotnet test ZB.MOM.WW.Overview.slnx # 177 tests, fully offline
cd docker && docker compose up -d --build # local rigs → http://localhost:5320 (start the rigs first)
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
# ZB.MOM.WW.LocalDb (~/Desktop/scadaproj/ZB.MOM.WW.LocalDb — a hosted shared lib)
dotnet build ZB.MOM.WW.LocalDb.slnx
dotnet test ZB.MOM.WW.LocalDb.slnx # 199 tests, fully offline (no live deps)
dotnet pack ZB.MOM.WW.LocalDb.slnx -c Release -o artifacts # 3 nupkgs @ 0.3.0
Shared GLAuth (all three apps + HistorianGateway): LDAP auth for every local dev/test stack is provided by a single
zb-shared-glauthcontainer on the Linux fixture host10.100.0.35:3893(baseDN dc=zb,dc=local, Transport=None). Source of truth and deploy runbook:scadaproj/infra/glauth/(config.toml+docker-compose.yml+README.md).
Refreshing this index
This file is meant to be re-scanned when scadaproj is opened in Claude Code:
- List sibling SCADA/OT directories:
find ~/Desktop -maxdepth 2 -iname "claude.md". - For each project the user wants indexed, read the top of its
CLAUDE.md(project overview + build/run sections) and update its row above. - Keep the project set curated — only the SCADA/OT/Wonderware/OPC-UA family belongs here.
- Flag new duplicates/overlaps and namespace mismatches rather than silently merging them.
Index edits go on main, and get pushed
Every sister project's CLAUDE.md tells it to update its entry here whenever a fact this
index records changes. That edit lands in this repo, which has its own checkout — so a
naive git commit writes it onto whatever branch scadaproj happens to be on, usually an
unrelated feature branch, sometimes one with no upstream. The index then disagrees with
reality for as long as that branch stays unmerged, which is the exact drift the rule exists
to prevent, and it is invisible unless someone goes looking.
So: commit index edits on main and push them. Stash or set aside unrelated work first
— never commit an index edit onto a dirty branch — and restore the original branch after:
git rev-parse --abbrev-ref HEAD # note it
git checkout main && git pull
# …edit the entry…
git commit -am "docs(index): <what changed about <project>>" && git push origin main
git checkout - # restore
Observed 2026-07-27: the OtOpcUa Sql poll driver's entry had sat unpushed on
feat/overview-dashboard since 2026-07-24 for exactly this reason, and the Mqtt driver's
entry was about to join it. Both were recovered by cherry-picking onto main (6ab50fe,
591eaac, 7dcd93c) — cherry-pick stranded index commits rather than merging the branch
they are stuck on, since that branch usually carries unrelated in-progress work.
Other workspace projects with a CLAUDE.md (not indexed — promote on request)
Listed so they can be pulled into the index above if you decide they belong.
SCADA/OT, de-indexed (still have a CLAUDE.md under ~/Desktop/):
- OpcUaTestServer — dual-instance OPC UA test server (.NET 10) for testing OPC UA clients / simulating automation.
- scada (ScadaLink) — design docs + scaffolding for the distributed SCADA platform (
ZB.MOM.ScadaLink.*). - scadalink-design-opcua-browser — ScadaBridge variant focused on OPC UA browser / Data Connection Layer work.
- DARS — DARS → Wonderware SCADA migration (design phase, Q2 2026 go-live).
- DARS_BU — backup snapshot of DARS.
- plan — 3-year SCADA IT/OT modernization roadmap (markdown).
Outside the SCADA family:
- delmia — DELMIA / Apriso (Intercim Velocity) MES customization export (ASP.NET
.asmx); MES-adjacent. - lightctrl — Raspberry Pi Python I/O control (edge hardware).
- codestats — Rust CLI for code statistics on .NET solutions/dirs (dev tooling for the .NET repos).
- servecli — Rust portable SSH/SFTP server for Windows remote task management (greenfield).
- JdeScopingTool — JD Edwards "LotFinder" .NET 4.8 → .NET 10 migration (ERP, not SCADA).
- chat — local-first roleplay chat engine.
- candy2 — candy.ai chat scraping / browser automation.
- menardslist — Menards.com cart → printable picklist PDF.