From 839f32910d34452cf9e79c0590427e176ff89260 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 16:49:40 -0400 Subject: [PATCH 1/5] ci: gate code-reviews README drift via regen-readme.py --check (#606) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .github/workflows/v2-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index 1f9128ec..b9263da5 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -33,6 +33,10 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x + # #606: code-reviews/README.md must be regenerated from the per-module findings.md + # files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5). + - name: code-reviews README drift check + run: python3 code-reviews/regen-readme.py --check - name: dotnet restore run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx - name: dotnet build From ad108f6a02632473339796622c24d348615bd443 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:02:44 -0400 Subject: [PATCH 2/5] test(mqtt): tag 16 wall-clock-bound tests Category=SlowUnit; document local opt-out (#606) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .github/workflows/v2-ci.yml | 3 ++- CLAUDE.md | 4 ++++ .../MqttConnectionTests.cs | 11 +++++++++++ .../MqttDriverDiscoveryTests.cs | 1 + .../MqttDriverSessionLifecycleTests.cs | 2 ++ .../SparkplugPrimaryHostTests.cs | 2 ++ 6 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index b9263da5..d2e81dd4 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -57,7 +57,8 @@ jobs: # Self-maintaining — a new *.Tests project is covered automatically, no matrix to widen — # and matches CLAUDE.md's own `dotnet test ZB.MOM.WW.OtOpcUa.slnx` guidance. Excludes only # the env-gated tiers: E2E (nightly, needs the docker-dev fleet) and LiveIntegration (needs - # a real gateway/PLC/VPN). The skip-gated *.IntegrationTests Assert.Skip when their fixtures + # a real gateway/PLC/VPN). Category=SlowUnit (Mqtt socket-timeout tests, #606) is deliberately NOT + # excluded here — it is a local-dev-loop opt-out only. The skip-gated *.IntegrationTests Assert.Skip when their fixtures # are unreachable, so they run harmlessly here (and are counted by the skip-gate below). - name: dotnet test (whole solution, minus E2E + LiveIntegration) run: > diff --git a/CLAUDE.md b/CLAUDE.md index 1c68f757..60374f76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -359,6 +359,10 @@ dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests # a single tes dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # a single test ``` +`Category=SlowUnit` (#606) tags the 16 wall-clock-bound Driver.Mqtt unit tests (blackhole connect-timeout / +reconnect-backoff windows, ~1 min of the project's 73 s). CI runs them (its filter excludes only E2E + +LiveIntegration); for a fast local loop add `--filter "Category!=SlowUnit"`. + CVE-2025-6965 / GHSA-2m69-gcr7-jv3q (transitive native `SQLitePCLRaw.lib.e_sqlite3`) is **resolved** — the temporary `NuGetAuditSuppress` was removed once the patched bundle shipped. `Core.AlarmHistorian` now carries a surgical direct pin of diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs index 9311df52..16a2e4a3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs @@ -30,6 +30,7 @@ public sealed class MqttConnectionTests /// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass /// whether or not any deadline logic existed. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang() { @@ -95,6 +96,7 @@ public sealed class MqttConnectionTests /// IMqttClient held across failures. A failed attempt must therefore leave the /// connection usable rather than poisoned. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt() { @@ -592,6 +594,7 @@ public sealed class MqttConnectionTests /// InitializeAsync) — it must not silently leave a background reconnect worker /// hammering an endpoint the caller has already given up on. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm() { @@ -729,6 +732,7 @@ public sealed class MqttConnectionTests /// Faulted) — and the backoff keeps the retry rate low enough that a handful of /// attempts, not hundreds, land in the observation window. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping() { @@ -759,6 +763,7 @@ public sealed class MqttConnectionTests /// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no /// later attempt resurrecting a connection the caller has torn down. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts() { @@ -875,6 +880,7 @@ public sealed class MqttConnectionTests /// against a perfectly healthy connection, at Debug level, inflating attempt so the /// NEXT genuine outage waits max backoff instead of min. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently() { @@ -917,6 +923,7 @@ public sealed class MqttConnectionTests /// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the /// two by a clear margin; widening the burn only widens the separation. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff() { @@ -971,6 +978,7 @@ public sealed class MqttConnectionTests /// dispose-elapsed assertion cannot tell those apart — DisposeAsync's own bounded join /// returns promptly either way, leaving the supervisor alive behind it. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor() { @@ -1085,6 +1093,7 @@ public sealed class MqttConnectionTests /// An unrecoverable rejection must not leave a supervisor grinding away every backoff period /// against a broker that will never accept these credentials. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor() { @@ -1156,6 +1165,7 @@ public sealed class MqttConnectionTests /// The supervisor's own attempts go through the same CONNACK check. A broker that starts /// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor() { @@ -1179,6 +1189,7 @@ public sealed class MqttConnectionTests } /// The mirror case: a transient refusal keeps the supervisor retrying under backoff. + [Trait("Category", "SlowUnit")] [Fact] public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs index ba49a6c1..f57d0c3b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs @@ -211,6 +211,7 @@ public sealed class MqttDriverDiscoveryTests /// fails must land rather than letting the failure escape /// with the health surface still claiming Healthy. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs index 38bdeaa0..5622a682 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs @@ -83,6 +83,7 @@ public sealed class MqttDriverSessionLifecycleTests /// The control for the test above: the normalization must not quietly rewrite a legal /// value, or a deployment's authored connect budget would silently become the default. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt() { @@ -126,6 +127,7 @@ public sealed class MqttDriverSessionLifecycleTests /// can drop a session (which is what this test needs from it) but cannot PUBLISH. /// /// + [Trait("Category", "SlowUnit")] [Fact] public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs index cfdcdd5a..48657c28 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs @@ -243,6 +243,7 @@ public sealed class SparkplugPrimaryHostTests /// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second /// loop ticks several times without the backoff window ever opening; advancing the clock opens it. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick() { @@ -313,6 +314,7 @@ public sealed class SparkplugPrimaryHostTests /// Deterministic rather than timing-dependent: the gate is taken before the connect starts, so /// the broker having accepted the socket proves the claim is inside it when dispose runs. /// + [Trait("Category", "SlowUnit")] [Fact] public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate() { From bdf13d29baed359e975b21d18d84247a0e40eb19 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:02:51 -0400 Subject: [PATCH 3/5] chore(docker-dev): LDAP bind password via OTOPCUA_LDAP_BIND_PASSWORD with dev default (#619) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docker-dev/README.md | 2 ++ docker-dev/docker-compose.yml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-dev/README.md b/docker-dev/README.md index 97da16a5..f7706820 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -102,6 +102,8 @@ The first build takes a few minutes (.NET SDK image + restore + publish). **No m Central nodes authenticate against the shared GLAuth at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`). `DevStubMode` is **not** active. Sign in with any test user (password `password`); `multi-role` / `password` returns all three roles (Administrator, Designer, Viewer). Group→role mappings are seeded by `seed/seed-clusters.sql`. The GLAuth source of truth + deploy runbook is in `scadaproj/infra/glauth/`. **Do not** enable `DevStubMode` outside local debugging — production must always bind a real LDAP backend. +The LDAP service-account bind password is read from the host env var `OTOPCUA_LDAP_BIND_PASSWORD` (compose default `serviceaccount123`, the committed dev password of the shared GLAuth, so the rig works with nothing set). Export it before `docker compose up` to bind with a different password (#619). + ## Headless deploy ```bash diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 395601a4..1b8391b0 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -194,7 +194,7 @@ x-ldap-env: &ldap-env Security__Ldap__AllowInsecure: "true" Security__Ldap__SearchBase: "dc=zb,dc=local" Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local" - Security__Ldap__ServiceAccountPassword: "serviceaccount123" + Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}" Security__Ldap__GroupToRole__ReadOnly: "ReadOnly" Security__Ldap__GroupToRole__WriteOperate: "WriteOperate" Security__Ldap__GroupToRole__WriteTune: "WriteTune" @@ -402,7 +402,7 @@ services: Security__Ldap__AllowInsecure: "true" Security__Ldap__SearchBase: "dc=zb,dc=local" Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local" - Security__Ldap__ServiceAccountPassword: "serviceaccount123" + Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}" Security__DeployApiKey: "docker-dev-deploy-key" Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}" # #520 stage 6 — node ACL enforcement, the dark switch. Default OFF, exactly as in production. @@ -535,7 +535,7 @@ services: Security__Ldap__AllowInsecure: "true" Security__Ldap__SearchBase: "dc=zb,dc=local" Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local" - Security__Ldap__ServiceAccountPassword: "serviceaccount123" + Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}" Security__DeployApiKey: "docker-dev-deploy-key" Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}" # Node ACL enforcement, the dark switch — same env override and same default-OFF as central-1. From 598cb133a8a5471313a1bcf4c04fc52c36828398 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:03:14 -0400 Subject: [PATCH 4/5] docs(historian): document deferred non-numeric types and UInt16->UInt4 fallback (#600) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/Historian.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/Historian.md b/docs/Historian.md index ae1568b3..5b7fb1c2 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -273,6 +273,22 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true` > so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy > keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above. +### Deferred types and the UInt16 fallback (#600 item 4) + +Historization is **numeric-analog only** in v1. `HistorianTypeMapper` classifies exactly nine types as +historizable (`Boolean`, `Int16/32/64`, `UInt16/32/64`, `Float32`, `Float64`): + +- **Deferred: `String`, `DateTime`, `Reference`.** The historian's SQL analog write path cannot carry them. + Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws + `NotSupportedException` for them, and `ContinuousHistorizationRecorder` drops their values and counts them + in `DroppedNonNumeric`. A tag authored `isHistorized` with one of these types therefore silently records + nothing; HistoryRead on it returns whatever the historian holds (typically `GoodNoData`). +- **Fallback: `UInt16 -> Uint4`.** The historian's `UInt2` write path is unproven upstream, so UInt16 tags + are provisioned as the wider `UInt4`. Values are unaffected; only the historian-side storage type differs. + (`Boolean -> Int2` is the other fallback, below.) + +Lifting either needs gateway-side support first; neither is scheduled. + ### Boolean historization maps to Int2 (issue #441) `EnsureTags` is contract-documented (HistorianGateway 0.2.0+) as an **upsert** — *"creates or updates From 9244ae2c31fcee495232ee49cbc81acf773e7b41 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:03:26 -0400 Subject: [PATCH 5/5] docs(mtconnect): feature status table; EVENT enums + DATA_SET/TABLE listed as unsupported (#613) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/MTConnect.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 1a8921d5..e29d226b 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -94,6 +94,19 @@ request/response handshake — is a different mechanism and remains deferred; se | `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) | | `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) | +### Feature status at a glance (#613) + +| Feature | Status | Detail | +|---------|--------|--------| +| SAMPLE / EVENT / CONDITION values | **Supported** | EVENT values are Strings carrying the Agent's own word | +| CONDITION as native Part 9 alarms (#626) | **Supported, opt-in** per raw tag via the `alarm` object | [CONDITION alarms](#condition-alarms-626) | +| `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) | +| One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) | +| Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) | +| EVENT controlled vocabularies as OPC UA enumerations | **Not supported** (stay String); not scheduled | See [Deferred](#deferred-not-in-this-build) | +| `DATA_SET` / `TABLE` representations | **Not supported** - read as `BadNotSupported` | [Quality mapping](#quality-mapping) | +| MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) | + **`IReadable.ReadAsync` is never called by the running server.** The production data plane is entirely `ISubscribable` — `DriverInstanceActor` subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the @@ -469,7 +482,7 @@ and records a state only when an alarm handler was attached to receive it. Conse re-subscribe; that is a host-wide gap shared with every native source.) - An alarm-only driver (no value tags) still runs the `/sample` pump. -**EVENT controlled vocabularies as OPC UA enumerations — won't fix.** EVENT values (`Execution`, +**EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority).** EVENT values (`Execution`, `ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type vocabulary table that vendors extend freely. @@ -571,7 +584,9 @@ for anything beyond values-and-conditions. - **P1.5 fast-follow** (design [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9 alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT - controlled-vocabulary values → OPC UA enumerations is **won't-fix**. + controlled-vocabulary values → OPC UA enumerations is **not built** (EVENTs stay Strings), and + structured `DATA_SET`/`TABLE` observations stay `BadNotSupported`; both are tracked as a low-priority + follow-up, not part of P1.5. - **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"` switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses auto-discovery (no device model without an Agent in front, so