Additive/wire-compatible releases across all three families; Directory.Packages.props
is the only pin surface (no csproj-level overrides, no lock files). Auth 0.3.0 adds
IApiKeyStoreMigrator and the optional Auth.ApiKeys.SqlServer package, neither needed
here. LocalDb 0.3.4's on-disk bookkeeping schema goes v2->v3 (upgrades in place on
open); 0.3.0 made the snapshot-resync flag per-table instead of per-database; 0.3.2
added validated Replication:KeepAlivePingDelay/KeepAlivePingTimeout config (defaults
60s/20s, left unset here) and a public LocalDbOplogGapException; 0.3.3 makes
RegisterReplicated throw LocalDbRegistrationException for a table that is the parent
of a row-mutating ON DELETE FK, which does not apply to this repo's four replicated
tables (no FKs). MxGateway.Client/Contracts 0.2.1 publishes the alarm snapshot_status
truncation frame and live alarm watch-list refresh; vendored the new nupkgs into
nuget-packages/ (the local-mxgw source MxGateway.* is exclusively package-source-mapped
to), matching the existing pattern from the 0.1.0->0.1.1 and 0.1.1->0.2.0 bumps.
Full solution builds clean (0 errors, 908 pre-existing analyzer warnings, unchanged
count). Full test suite: one failure, ClusterRoleInfoRoleLeaderCaseTests.
RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries, reproduces
identically on the unbumped 0.2.1/0.2.1/0.2.0 tree under the same full-suite parallel
run (and passes standalone in isolation in 2s on the bumped tree) - a pre-existing
timing/parallelism flake, not a bump regression, so left as-is.
Updated CLAUDE.md and two docs (Configuration.md, operations/2026-07-20-localdb-pair-
replication.md) where they stated the old pin numbers and the now-superseded
per-database snapshot-resync behavior as current fact; dated historical records
(archreview snapshots, dated plan docs) were left untouched.
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
Closes the pin skew against ScadaBridge (which already pins 0.2.1). Pure pin
bump — no code change was required:
- `RegisterReplicated(string)` still delegates to `baselineExistingRows: false`,
so `LocalDbSetup.OnReady` is byte-identical in behaviour.
- Everything 0.2.x added is additive (`DeregisterReplicated`, `MaxBatchBytes`,
the `baselineExistingRows` overload); none of it is called here.
- The wire protocol is unchanged (0.1.x peers still sync) and the on-disk
bookkeeping schema v1 -> v2 upgrades in place on open, so no rig, volume or
operator action is needed.
The one thing that DID need reconciling is documentation. 0.2.0 made batching
byte-budgeted: `MaxBatchBytes` (2 MB default, under the 4 MB gRPC cap) now
bounds the wire message, and `MaxBatchSize` is demoted to a secondary row cap
plus the DB read-page size. The rig's `MaxBatchSize = 16` was chosen as a
hand-computed byte proxy ("row-count-only against gRPC's 4 MB cap"), and that
rationale is now wrong everywhere it appeared — appsettings, Configuration.md,
the pair-replication runbook, and both docker-dev site-a services.
The VALUE stays 16, for a different and still-real reason: OplogStore /
SnapshotStreamer materialise a whole read page in memory *before* the
byte-budget split runs, so 16 x ~171 KB artifact chunk rows bounds that
transient allocation at ~2.7 MB. Documented as such, with `MaxBatchBytes`
added to both config tables.
CLAUDE.md records the pin and two deliberate non-adoptions: this repo does not
take ScadaBridge's conditional-registration + boot-time `DeregisterReplicated`
self-heal (OnReady registers all four tables unconditionally, and
LocalDbSetupTests pins that set), and consequently does not use
`baselineExistingRows` either — so enabling replication on a long-running
default-OFF node converges on subsequent writes, not on rows already in the
file. Both are behaviour changes, out of scope for a pin bump.
Verification: solution builds clean; full suite run (57 projects). All 66
LocalDb tests in Host.IntegrationTests green. One unrelated failure,
RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_
fails_with_disposed — a Roslyn evaluator disposal race with no LocalDb in its
path, whose own comment documents that its "at most one clear per evaluation"
assumption depends on thread-pool pressure. It passed 3/3 re-runs in isolation
and fails only under full-solution parallel load; pre-existing test-design
fragility, not a regression from this bump.
Audited every ZB.MOM.WW.* pin against what is actually published. Fifteen of
seventeen were already current; two were behind, for different reasons:
- Auth 0.1.5 -> 0.2.0 was published on the Gitea feed and simply not adopted.
- MxGateway 0.1.1 -> 0.2.0 was never adopted because these two packages resolve
from the checked-in ./nuget-packages folder feed, not from Gitea, so a bump
needs the .nupkg copied in from ~/Desktop/MxAccessGateway/dist/. Nothing
reports the folder as stale; it just quietly keeps serving the old version.
0.1.2 (MxSparseArray) was skipped over entirely for the same reason.
Both are purely additive at the API surface (diffed the shipped XML docs member
by member: zero removals; Contracts +19, Client +45).
Auth 0.2.0 adds LDAP endpoint failover -- an ordered FallbackServers list with
sticky endpoint preference. Its one new validation rule rejects a malformed
FallbackServers entry at boot, which cannot bite us: we never set the key and it
defaults to empty. Net behaviour change here is zero; the failover is available
if AdminUI login ever wants a backup DC.
MxGateway 0.2.0 adds sparse-array values, replay-gap stream items, secret
redaction helpers and a batch of session RPCs. Two notes on the value path:
- MxValueDecoder's `_ => null` fallback already absorbed an unknown MxValue kind,
so a sparse array degrades to null on the old and new contracts alike -- the
bump does not change what we decode, it only makes handling it possible. Same
for MxEvent.ReplayGap, which EventPump ignores; we stream with
afterWorkerSequence: 0 and re-prime from the subscription on reconnect rather
than relying on replay. Both are open follow-ups, not regressions.
- Staying on 0.1.1 was never protection: the gateway decides what goes on the
wire, and prod already runs a server past this contract.
We pin no gateway contract version anywhere, so there is no handshake to update.
Verified: restore clean, build 0 errors, and the warning census is unchanged
(the 20 CS0618 obsoletes are all OPC UA SDK calls, none from these packages).
Every project that references either package is green -- Galaxy 317, Galaxy
Browser 21, Security 89, Host 106, AdminUI 1499 = 2032 tests, 0 failures.
Not updating the scadaproj index: its OtOpcUa entry names the shared libraries
but records no version pins, and which libraries we consume has not changed.
Four-minor jump 0.2.3 → 0.6.2. The release line exists because a deploy
renaming the app directory took the MxGateway credential store with it
(2026-08-09, two silent days): Secrets:SqlitePath must now be absolute AND
outside the content root, rejected at boot rather than relocated. 0.6.2 over
0.6.1 is message-only (the rejection text now teaches the mounted-volume
case and forecloses the override hunt) — the live rig evidence below was
gathered at 0.6.1 and the validator behavior is identical.
Adoption changes beyond the pins:
- appsettings.json drops the relative "otopcua-secrets.db" (now boot-fatal);
dev gets the library's absolute per-user default, deployments supply their
own (wonder already pins an absolute out-of-root path in the service env).
- Program.cs's pre-host ${secret:} expander now calls the FOUR-argument
AddZbSecrets with builder.Environment.ContentRootPath. This call site is
the actual fix, not the pin: the throwaway container has no
IHostEnvironment, and without the explicit root the under-content-root
rule is skipped at exactly the moment the migrator creates the store file
(the 0.6.0 defect). Do NOT simplify back to the 3-argument overload — it
compiles and silently reopens the gap. Pinned by a Program.cs source-scan
test AND a runtime falsification control that RUNS the migration and
demonstrably creates the store at the rejected path under the 3-arg form.
- docker-dev: Secrets__SqlitePath moves /app/data → /data via a SECOND mount
of the same per-node volume (same file at the volume root, so seeded
stores carry over) because /app is the container's content root and the
new rule correctly rejects it. LocalDb stays on /app/data (secrets-only
rule).
- SecretsStorePathValidationTests: positive-control probes, artifact-first
(relative and in-content-root paths must FAIL leaving no file; outside-
root passes; the no-explicit-root control proves the file assertions can
fail) — a clean boot is equally consistent with the validator never being
wired.
Live-verified on the six-node docker-dev rig (at 0.6.1): all nodes boot
under the new validation with stores at /data; an AdminUI-written secret
converged into BOTH central nodes' independent stores; site-a held only its
own pair's secrets (Phase 6 pair-local isolation intact). Replicator wire
behavior unchanged 0.3.0–0.6.x per CHANGELOG; registration-graph pins and
the full suite pass.
Adds the /current and /sample legs to MTConnectAgentClient:
- MTConnectStreamsParser: hand-rolled System.Xml.Linq parse of an
MTConnectStreams document into MTConnectStreamsResult. Observations are
every element child of <Samples>/<Events>/<Condition>, keyed by
dataItemId (the element NAME is the data item's type in PascalCase, so
a fixed name set would drop observations). A CONDITION's value is its
element name, not its text - <Normal/> is empty - and <Unavailable/>
normalizes onto the same UNAVAILABLE sentinel a Sample/Event carries as
text, so Task 8's no-comms mapping sees one token. Timestamps are
normalized to DateTimeKind.Utc explicitly.
- IsSequenceGap(requestedFrom, chunk) => chunk.FirstSequence >
requestedFrom, so Task 11's pump can re-baseline after a ring-buffer
overflow. Equality is NOT a gap.
- SampleAsync: ResponseHeadersRead + a hand-rolled
multipart/x-mixed-replace frame reader (Content-length fast path,
boundary-scan fallback), bounded by a heartbeat watchdog derived from
HeartbeatMs so a silent Agent faults instead of wedging the pump - the
S7 frozen-peer shape (arch-review R2-01). The long poll gets its own
HttpClient with an infinite Timeout so the unary /probe + /current
deadline stays bounded.
Task 0 package decision, option (c): both TrakHound PackageReferences and
their PackageVersions are dropped. MTConnectHttpClientStream takes a URL
and dials its own socket (no handler/stream seam, so it cannot serve a
socket-free unit suite), and it emits already-parsed documents through the
formatter lookup Task 6 proved is missing - framing-only reuse was never
actually on offer. The driver now depends on nothing but the BCL. Recorded
in the plan's Task 0 CORRECTION block.
Also folds in two Task 6 review carry-overs: the stale
IMTConnectAgentClient doc comment claiming a TrakHound-backed
implementation, and a probe test constructing a mixed-namespace vendor
extension (<x:Pump> with default-namespace <DataItems> children) that
backs the parser's ignore-the-namespace claim with a test.
187/187 green.
Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by
reflection, not by copying siblings. All are client-visible: OPC UA clients branch on
status.
The three named in #497:
- Historian.Gateway SampleMapper "BadNoData" 0x800E0000 -> 0x809B0000
(0x800E0000 is BadServerHalted)
- Galaxy "BadTimeout" 0x800B0000 -> 0x800A0000
(0x800B0000 is BadServiceUnsupported)
- TwinCAT BadTypeMismatch 0x80730000 -> 0x80740000
(0x80730000 is BadWriteNotSupported)
BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS,
AbLegacy and AbCip carried the same 0x80730000. Every call site is a
FormatException/InvalidCastException conversion failure, so the name was right and
the value was wrong in all four.
Adding the reflection guard then surfaced nine further defects offline tests had
never checked:
- Galaxy GoodLocalOverride 0x00D80000 -> 0x00960000 (not a UA code at all)
- Galaxy UncertainLastUsableValue 0x40A40000 -> 0x40900000
- Galaxy UncertainSensorNotAccurate 0x408D0000 -> 0x40930000
- Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000
- Galaxy UncertainSubNormal 0x408F0000 -> 0x40950000
- TwinCAT BadOutOfService 0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported)
- TwinCAT BadInvalidState 0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid)
- AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent)
- Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000
Galaxy's whole Uncertain block read as though the OPC DA quality byte could be
shifted into the UA substatus position; it cannot — the substatuses are an unrelated
enumeration. GatewayQualityMapper already held the correct table, which is what the
Galaxy values are now reconciled against.
Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project-
references every driver) reflects over every `const uint` in the deployed
ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it
equals Opc.Ua.StatusCodes.<name>. Discovery is by convention, so a new driver
assembly referenced by that project is covered with no edit here. It went red on all
nine defects above before the fixes and now checks 109 constants green.
Because reflection can only see a NAMED constant, two inline literals were hoisted so
the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and
GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline
`0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived.
The drivers stay SDK-free by design; only the test project references Opc.Ua.Core,
as the oracle.
Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests,
SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source),
a mislabelled comment in DriverInstanceActorTests, and stale constants in two design
docs, one of them the unexecuted MTConnect driver design that would have propagated
BadNoData's wrong value into a new driver.
The CLI's SnapshotFormatter value->name table was checked against the SDK and is
correct; no change needed.
ClusterPrimaryHealthCheck was written three days' worth of debugging ago because
the shared ActiveNodeHealthCheck selected by RoleLeader and reported Healthy for
any node lacking the role. Health 0.3.0 fixes the shared check — oldest Up
member, role-preference scoping, Unhealthy when the node owns no active work —
so the private copy is now duplication rather than a workaround, and it is
deleted.
RolePreference [admin, driver] reproduces exactly what it did: a fused central
node answers for admin (where the singletons and the AdminUI are pinned), a
driver-only site node answers for driver.
SelectOldestUpMemberOfRole now delegates to the shared ClusterActiveNode instead
of re-implementing the age ordering. This is the point of the change rather than
a tidy-up: the redundancy snapshot drives the OPC UA ServiceLevel 250/240 split
while the health tier drives Traefik's admin routing, so two copies of "oldest Up
member of a role" would be two chances to advertise one node as authoritative
while gating the data plane on another. ControlPlane takes a ZB.MOM.WW.Health.Akka
reference for it; the layering trade-off is recorded at the PackageReference.
Behaviour note: a node carrying neither admin nor driver now answers 503 on the
active tier instead of 200. That case is reachable — RoleParser admits dev-only
and cluster-role-only nodes — and 503 is correct, since such a node owns no
active work and must not be in an active-tier pool. No docker-dev node is
affected: every rig node is admin+driver or driver-only.
Tests replaced in kind. The rule itself is now pinned in the library against a
real two-node cluster; what stays OtOpcUa's own decision is the wiring, so
ActiveTierRegistrationTests pins which check is on the active tag, that it is
registered on driver-only nodes too, and that it is not on the ready tier.
Verified: Host.Tests 25/25, ControlPlane redundancy 15/15.
0.2.1 makes the shared ActiveNodeHealthCheck return Unhealthy (503), not
Degraded (200), for a node that carries the role but is not the role leader.
That is what the shared health spec always specified, what docs/ServiceHosting.md,
docs/v2/Architecture-v2.md and the 2026-05-26 alignment design all describe
("200 only on the Admin-role leader; 503 elsewhere"), and what
docker-dev/traefik-dynamic.yml + scripts/install/traefik-dynamic.yml assume when
they use /health/active as the load-balancer probe.
The implementation returned Degraded, which MapZbHealth maps to 200, so BOTH
central nodes always passed the probe and Traefik load-balanced the admin UI
across the pair instead of pinning it to the leader. The docs were right; the
code was wrong. Closes the Traefik half of #494.
No OtOpcUa code change — package pin only.
Live-verified on docker-dev, with the MAIN pair genuinely formed (memberCount 2,
both members naming one leader):
central-1 (leader) /health/active -> 200 traefik UP
central-2 (standby) /health/active -> 503 traefik DOWN
Failover drill (README step 2), which had never actually exercised anything:
stopping central-1 flipped Traefik to central-2 within 20 s and
http://localhost:9200/ answered 200 continuously across the swap — no outage.
Restarting central-1 reclaimed the role-leader and Traefik flipped back.
README corrected on two counts: the drill now states that exactly ONE backend is
UP by design, and step 3 no longer claims central-2 keeps leadership — RoleLeader
is the lowest-address member, so central-1 deterministically reclaims it. Added
the cold-boot caveat that starting both nodes at once can leave each self-forming
a 1-member cluster, in which case both are their own leader and both answer 200.
STILL OPEN in #494: driver-only nodes. The check is scoped to the admin role and
returns Healthy for any node that lacks it, so all four site nodes answer 200 and
the real per-Cluster redundancy Primary (oldest Up driver member, IsDriverPrimary)
is still not what this tier reports.
Picks up the additive per-entry `data` object in the canonical health JSON and
the cluster-view data the shared AkkaClusterHealthCheck now publishes (leader /
selfAddress / selfRoles / memberCount / unreachableCount).
No OtOpcUa code change is needed: the "akka" ready/active check is already the
shared ZB.MOM.WW.Health.Akka.AkkaClusterHealthCheck (Health/HealthEndpoints.cs),
so /health/ready starts carrying entries["akka"].data.leader on the bump alone.
Payloads from every other check are byte-identical — data is emitted only when a
check publishes some.
Prereq for the family overview dashboard (scadaproj
docs/plans/2026-07-22-overview-dashboard-impl-plan.md, Phase 2).
Verified: Host 21/21, AdminUI 692/692, Host builds 0 warnings.
Live rig check (admin node serves data.leader) deferred to the dashboard's
acceptance run.
The repo has consumed packaged gRPC clients (LocalDb.Replication,
HistorianGateway.Client) but never compiled a proto locally. This adds the
Grpc.Tools toolchain to Commons and a deployment_artifact.proto with a single
server-streaming Fetch(deployment_id) -> stream ArtifactChunk RPC, generating
both the server base and the client stub (GrpcServices="Both") so central
(Host/AdminUI) and the node (Runtime) share one reference. A compile-touch test
pins the codegen: it stops compiling if generation regresses.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Four NU1903 high-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h,
GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f) landed in the NuGet audit data against
System.Security.Cryptography.Xml 10.0.7, which Microsoft.AspNetCore.DataProtection
10.0.7 pulls in transitively for key storage. Under TreatWarningsAsErrors a fresh
restore produces 204 NU1903 errors and the solution does not build at all.
This was invisible locally: machines with cached audit data keep building, so the
break only shows on a clean clone or a docker image build. It surfaced here by
accident — a detached worktree at the pre-change baseline, created to check
whether a flaky test predated today's work, could not restore. Worth noting that
the diagnostic path was the accident, not the intent: nothing in the normal build
or test loop would have reported this before someone else hit it.
Same surgical-pin pattern as the SQLitePCLRaw entry directly above it: a direct
PackageReference at the one project where the chain enters the repo
(Core.Configuration), rather than CentralPackageTransitivePinningEnabled, which
this repo already documents as breaking the Roslyn version split. Bumping the
DataProtection parent to 10.0.10 instead was rejected for the reason the sister
repo recorded when it hit the same advisories: 10.0.10 floors
Microsoft.Extensions.* and, via the EFCore adapter, Microsoft.EntityFrameworkCore
at 10.0.10, forcing a family-wide servicing bump and an NU1605 downgrade cascade
that deserves its own reviewed change.
Verified the way the defect demanded — in a fresh worktree, not this one: restore
clean (0 errors, down from 204) and full solution build clean.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Moves the alarm store-and-forward buffer out of its own alarm-historian.db
and into the node's consolidated LocalDb, where it replicates to the
redundant pair peer. A node that dies holding undelivered alarm history no
longer takes it to the grave.
Tasks 2 and 3 land together, as the plan anticipated. They are not separable:
the gate is a constructor argument of the rewritten sink, and a commit that
replicated the queue without gating the drain would be a commit in which both
nodes of a pair deliver every alarm event, continuously.
That drain gate is the load-bearing part of the change, and the recon
explains why it is new work rather than a refinement. Exactly-once delivery
across a pair is enforced today on the ENQUEUE side, by
HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is
empty and its ungated drain has nothing to send. Replicating the table
destroys that invariant.
The gate is a Func<bool> the caller supplies, because the drain runs on a
timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot
reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new
IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate
verdict to -- the same verdict the inbound-write and native-ack gates use, so
there is no second notion of am-I-the-Primary to drift.
Two failure modes are deliberately closed:
- The view is seeded OPEN, matching the policy's own answer for an unknown
role with no driver peer. A deployment that runs no redundancy never
publishes to it, and defaulting closed would silently stop its alarm
history forever.
- A gate that throws is read as not-now, never as permission, and a closed
gate reports the new HistorianDrainState.NotPrimary rather than Idle. A
Secondary's rising queue is supposed to look different from a stalled
drain, and if BOTH nodes report NotPrimary the pair is misconfigured and
says so instead of quietly filling toward the capacity ceiling.
Row ids are a hash of the payload rather than fresh GUIDs. Both adapters
accept the same fanned transition in the window before the first redundancy
snapshot arrives, and under last-writer-wins an equal key converges those two
accepts into one row instead of duplicating them.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The gate closed both follow-ups on the docker-dev rig and found a third defect
that offline tests could not have found first: it lived behind the one being
fixed. Once 0.1.2 made back-fill work, a rebuilt node could be observed writing
for the first time — and its writes went nowhere, because the healthy node kept
the old peer's seq watermark. Library 0.1.1 -> 0.1.2 -> 0.1.3 over the course
of the gate; both consumers now pinned to 0.1.3.
Check 4 (was PARTIAL, now PASS): with SQL stopped and site-a-2's LocalDb volume
destroyed, a-1 logged "Snapshot sent (as_of_seq 0, 4 rows)" — a line that could
not appear on 0.1.1 — and a-2 came back with a row_version dump byte-identical
to a-1's, carrying a-1's origin node ids and its ORIGINAL applied_at_utc. It
then booted from cache and served 17 ns=2 nodes, diff-identical to its peer,
from a configuration it never applied and could not have fetched.
Check 8 (PASS): two restarts with an unchanged artifact left the oplog at 7 and
the pointer timestamp frozen — the re-cache was skipped outright. Positive
control: a real config change still wrote, 7 -> 10 -> 13.
Also records one unrelated pre-existing finding the gate's SQL flapping
surfaced: a transient ConfigDb error during artifact load empties the served
address space while logging "rebuild becomes no-op". The cache layer correctly
refused to store the bad artifact and the node recovered on restart, but the
two logs disagree and it is the same class as the Phase 1 gate's check-3
defect. Untouched by this work; wants its own issue.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
1. Wiped-node back-fill (live gate check 4). Fixed upstream in
ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly
off: the oplog cap was not the gate. Snapshot detection measured the peer's
gap against the oldest surviving oplog row and read an empty oplog as "no
gap possible" — and an empty oplog is the steady state of a converged pair,
since ack-pruning deletes everything the peer confirmed. The healthy state
was the one state that could not heal a wiped peer. Now measured against
last_acked_seq when the oplog is empty.
Pinned at this level too, not just the library's: the pair harness grows a
WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node
id and a zero watermark), and the new scenario asserts the emptied oplog as
its precondition before wiping, then requires the deployment artifact to
come back byte-identical with no new deploy. Verified RED against the pinned
0.1.1 (times out waiting for back-fill) and green on 0.1.2.
2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source:
StoreAsync now skips entirely when the pointer already names this
deployment/revision, the SHA matches the bytes, and the expected chunk count
is present. Re-caching an artifact the node already holds — every restart's
boot-from-cache, every RestoreApplied — writes nothing and mints no oplog
rows, where before it cost a delete plus an insert per chunk plus a pointer
update, all identical to what was already there.
Identity is over the bytes and the chunks are counted, both deliberately: a
re-composed artifact can carry the same ids with different bytes, and a
pointer can name a deployment whose chunks are missing, where skipping would
make an unreadable cache permanent. Both guards verified RED against a naive
pointer-only skip.
The gate's stated bound was also wrong and is corrected in the doc: growth
was never headed for the 1M row cap. AddZbLocalDbReplication is registered
unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes
and the 7-day MaxOplogAge cap prunes.
Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and
unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a
clean master too) and a flaky Roslyn race test (green 2/2 in isolation).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs
invites the next reader to wire the wrong one.
Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's
list and would have failed to compile (it calls new LiteDB.LiteDatabase directly).
StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too.
The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed:
it now records that no sign-in fallback exists and that reviving one means an admin-side
cache on LocalDb.
Aligns OtOpcUa with the rest of the family — mxgw, HistorianGateway and
ScadaBridge have all been on 0.1.5; OtOpcUa was four versions behind and the
only outlier.
The bump is functionally inert here, deliberately so. OtOpcUa consumes
Abstractions + Ldap + AspNetCore, NOT ApiKeys, and every intervening change was
in ApiKeys: 0.1.2 stamped its schema version, 0.1.3 added SetScopes/SetEnabled,
0.1.4 added ExpiresUtc. 0.1.5 is the patched-SQLitePCLRaw pin
(GHSA-2m69-gcr7-jv3q), which this repo already carries independently as its own
surgical transitive pin at Directory.Packages.props:105 — so no vulnerability
was open here either.
The value is removing a false signal: a repo sitting four versions behind reads
as "missing something" every time someone audits the family, and the next real
Auth change would have arrived as a four-version jump instead of one.
Verified: build 861 warnings / 0 errors, byte-identical to the master baseline
(measured, not assumed — master was built to compare). Security 84, AdminUI
665, Configuration 121, ControlPlane 78 — all pass, 0 failures.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
0.2.3's Secrets.Ui ships ConfirmDeleteModal's own styles under
collision-proof zb-secrets-* class names; on 0.2.2 this host's Bootstrap
.modal{display:none} made the /admin/secrets delete modal permanently
invisible (confirmed live on the docker-dev rig). No other library change
affects this consumer. AdminUI tests 665/665; secrets Host.IntegrationTests 8/8.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
Bumps the four ZB.MOM.WW.Secrets pins 0.2.1 -> 0.2.2 (closes the OtOpcUa side
of scadaproj#1, tracked here as #482). 0.2.1's Akka replicator deadlocked any
hosted process at startup when Secrets:Replication:Enabled was true: the
package's DI graph closed a circular singleton dependency through factory
lambdas (store decorator -> replicator -> actor provider -> cache invalidator
-> resolver -> store), which MS.DI's StackGuard turns into a silent
cross-thread call-site-lock deadlock. 0.2.2 defers the invalidator edge to
first eviction. The flag stays default-false; enabling remains a
per-environment decision.
The_startup_hook_actually_creates_the_replication_actor is now a real test:
SecretReplicationStarter's docs had promised it since the adoption, and the
upstream fix finally makes a provider-based resolve runnable - container built
exactly as the host does, hook started under a watchdog, replication actor
proven to exist by ActorSelection on a self-joined single-node cluster (no
TestKit needed, which matters because Akka.TestKit.Xunit2 is xunit-v2-only and
this project is on xunit.v3). Also corrects the stale rationale that blamed
the old hang on DistributedPubSub needing a joined cluster - the actor
constructor was never reached; it was the DI cycle.
Verified: SecretsReplicationRegistrationTests 8/8 on the 0.2.2 feed packages;
full slnx build 0 errors; the 2-node Akka live convergence gate re-run against
the published 0.2.2 packages passes 6/6 (write->peer, tombstone propagation
without resurrection, delete visibility through the resolver cache, reverse
direction, wrong-KEK fail-closed).
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
0.2.0's AddZbSecretsAkkaReplication never bound its own ISecretReplicator: it
called AddZbSecrets first, which TryAdds NoOpSecretReplicator, so the package's
own TryAdd was silently discarded. Replication published into a no-op sink and no
actor was ever spawned - no exception, no log line. Found by the Task 6 wiring
work, which is the first code that ever built a container around that extension.
Fixed upstream in 0.2.1.
Test consequence worth recording: the replication-enabled registration tests
previously passed against a plain ActorSystem only BECAUSE of that bug - the
no-op never touched Akka. With 0.2.1 they resolve a real replicator, which spawns
the actor, whose PreStart needs DistributedPubSub and therefore a joined cluster,
so provider-based assertions hang. Standing a real cluster up inside this test
assembly was attempted and did not work, so these assertions are now made against
the ServiceCollection, which is precisely where the defect lives. Actor creation
and convergence remain covered upstream by the library's TwoNodeClusterReplication
tests against a genuine 2-node cluster.
7 registration tests pass in 23ms. Full solution builds 0 errors; no warnings
originate from these files.
Version hygiene + picks up the G-8 KEK-rotation surface, and is the
precondition for adopting clustered secret replication. NOT a security fix
for this repo: SQLitePCLRaw.bundle_e_sqlite3 was already pinned to 2.1.12.
Adopt the ZB.MOM.WW.Secrets library (0.1.2, Gitea feed) into OtOpcUa under CPM:
- Directory.Packages.props + NuGet.config source mapping for the three packages
- Host/AdminUI/Driver.Galaxy.Contracts/Driver.OpcUaClient package references
- Layer-A pre-host ${secret:} config expander in Host/Program.cs, placed after all
config providers assemble and before the first config read (mechanism only,
behavior-neutral no-op until Task 4 introduces tokens)
- Secrets section in appsettings.json (env-var KEK, SQLite store, 30s cache TTL)
Mechanism mirrors the proven HistorianGateway adoption. KEK never committed.
The patched native SQLite bundle shipped: SQLitePCLRaw.bundle_e_sqlite3 2.1.12
is the first version outside the CVE-2025-6965 / GHSA-2m69-gcr7-jv3q vulnerable
range (<= 2.1.11), embedding the SQLite 3.50.2+ fix.
- Bump Microsoft.Data.Sqlite 9.0.0 -> 10.0.7 (aligns with the EF Core 10.0.7 family).
This alone still pulls the native bundle 2.1.11, so:
- Add a surgical direct pin of SQLitePCLRaw.bundle_e_sqlite3 2.1.12 in Core.AlarmHistorian
(the sole consumer), overriding the transitive 2.1.11 without enabling
CentralPackageTransitivePinningEnabled (which breaks the Roslyn 5.0/4.12 split).
- Remove the temporary NuGetAuditSuppress from Directory.Build.props.
Verified: dotnet restore/build clean with audit active (no GHSA-2m69), every
in-solution project resolves lib.e_sqlite3 2.1.12, AlarmHistorian tests 29/29 green
against the real native bundle.
Closes#458
Branch B (upstream-blocked): probed nuget.org for an xunit.v3 Akka TestKit —
Akka.TestKit.Xunit3 and Akka.TestKit.Xunit.v3 both return BlobNotFound (verified
2026-07-13), so the 3 holdouts (AdminUI/ControlPlane/Runtime.Tests) cannot migrate
off xunit v2 yet. Bumped the pin 2.9.2 -> 2.9.3 (latest 2.9.x) and moved the blocker
rationale + removal condition to a comment at the pin in Directory.Packages.props so
it lives at the version, not just in 3 scattered csprojs. All 3 suites green on 2.9.3
(514 + 63 + 364 tests).
Consume the published 0.2.0 gateway packages. M3 opc_quality gate checked
and CLEAR: OtOpcUa never writes HistorianHistoricalValue (uses only the
WriteLiveValues path, no opc_quality field), and every OpcQuality=0 is on
the read-side HistorianSample in test fixtures — unaffected by 0.2.0's
HistorianHistoricalValue.opc_quality proto3-optional change. Build clean;
gateway-driver 99/0 and runtime 364/0 tests pass.
DriverHostActor/DriverInstanceActor and cluster events log via Akka's ILoggingAdapter, which had no Serilog bridge — under the Windows service host the default StandardOutLogger output is discarded, making the driver-role actor graph invisible (this masked the data-plane diagnosis).
- Add Akka.Logger.Serilog 1.5.60 (deps satisfied by Akka 1.5.62 / Serilog 4.3.1).
- WithOtOpcUaClusterBootstrap: ConfigureLoggers(DebugLevel; ClearLoggers(); AddLogger<SerilogLogger>()) — Akka.Hosting owns logger setup, so HOCON akka.loggers alone is not honored.
- Program.cs: set static Serilog.Log.Logger from the DI root logger after Build() (AddZbSerilog registers the MEL provider but not the static logger, which the Akka SerilogLogger and the startup banner both need).
User-published 0.1.1 of the MxGateway client + contracts packages into the
local-mxgw vendored source (nuget-packages/). Bumps Directory.Packages.props to
match and adds the 0.1.1 .nupkg artifacts alongside the existing 0.1.0 ones.
Full solution builds clean against 0.1.1.
All five suppressed advisories are now resolved at baseline/resolved versions,
so every NuGetAuditSuppress is removed repo-wide:
- System.Security.Cryptography.Xml (GHSA-37gx-xxp4-5rgx / GHSA-w3x6-4m5h-cxqf)
-> fixed by the .NET 10 baseline (10.0.6)
- OPCFoundation Opc.Ua.Core (GHSA-h958-fxgg-g7w3) -> fixed at resolved 1.5.378.106
Two were still live and are now patched via direct security pins:
- OpenTelemetry.Api 1.9.0 -> 1.15.3 (GHSA-g94r-2vxg-569j) pinned in Cluster;
Runtime/ControlPlane/AdminUI + tests inherit via project reference
- Tmds.DBus.Protocol 0.20.0 -> 0.21.3 (GHSA-xrw6-gwf8-vvr9) pinned in Client.UI
Also correct the Historian sidecar runtime comments (x86 -> x64, matching the
csproj PlatformTarget). Solution audit: 0 vulnerable packages; full build clean.
LZ4-decompression out-of-bounds read in MessagePack < 2.5.301 (transitive
via the Historian.Wonderware driver). 2.5.301 is the patched v2-series
release; stays on v2 (no v3 major bump). Restore no longer fails NU1903.
Opc.Ua.Server was pinned 1.5.374.126 while Client/Configuration were 1.5.378.106, so the
published Host unified Opc.Ua.Core to 1.5.378 (which dropped Opc.Ua.INodeIdFactory that Server
1.5.374 referenced). Every driver-role node (and the fused site nodes) failed to start the OPC
UA server with TypeLoadException, leaving the OPC data plane dead and the site UIs at 503.
Align all OPC UA packages to 1.5.378.106 (bump Server; drop the Opc.Ua.Configuration/Client
VersionOverrides in OpcUaServer + its integration tests) and port the server host to the
1.5.378 async API:
- ApplicationInstance requires an ITelemetryContext ctor (DefaultTelemetry.Create)
- Start/Stop/LoadApplicationConfiguration/Validate -> async; CheckApplicationInstanceCertificate
-> CheckApplicationInstanceCertificatesAsync
- ImpersonateEventHandler is now (ISession, ImpersonateEventArgs)
- UserNameIdentityToken.DecryptedPassword is now byte[] (UTF-8 decode)
- tests ported (byte[] passwords; async discovery/session/read client API)
Verified: full solution builds, OpcUaServer unit tests 52/52, and in docker-dev all six OPC
endpoints (4840-4845) listen and the site UIs return 302 (were 503). End-to-end OPC behaviour
(read/write/subscribe/security under 1.5.378) still needs a functional client test.
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed
and pins Auth.Abstractions/Ldap/AspNetCore + Audit at 0.1.0. No project
references yet (added during Phase 1/2 adoption). OtOpcUa omits Auth.ApiKeys
(OPC UA transport security).