Commit Graph

2383 Commits

Author SHA1 Message Date
dohertj2 57003a23a5 Merge pull request 'fix(redundancy): 2-node SBR exit-and-rejoin recovery (#459)' (#464) from fix/459-sbr-recovery-scadabridge-parity into master
v2-ci / build (push) Successful in 5m12s
v2-ci / unit-tests (push) Failing after 13m10s
2026-07-15 10:54:04 -04:00
Joseph Doherty b0eb653bad fix(redundancy): 2-node SBR exit-and-rejoin recovery — watchdog + restart supervision + both-node seeds (#459)
v2-ci / unit-tests (pull_request) Failing after 12m9s
v2-ci / build (pull_request) Successful in 5m12s
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.

Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.

Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
  ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
  process exits (supervisor restarts it) instead of idling with a dead actor system.
  Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
  both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
  fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
  requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
  non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
  ANY single loss needs 3+ members.

Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
2026-07-15 10:52:36 -04:00
dohertj2 b980eeb148 Merge pull request 'docs(archreview): live finding — keep-oldest downs the survivor on oldest-crash (#459)' (#462) from docs/459-oldest-crash-live-finding into master
v2-ci / build (push) Successful in 4m33s
v2-ci / unit-tests (push) Failing after 10m32s
2026-07-15 10:03:49 -04:00
Joseph Doherty d7470c7960 docs(archreview): live finding — keep-oldest downs the survivor on oldest-crash (#459)
v2-ci / build (pull_request) Successful in 4m27s
v2-ci / unit-tests (pull_request) Failing after 11m36s
2-container docker-dev rig: hard-killing the oldest node makes the younger
survivor down ITSELF (SBR DownReachable including myself -> CoordinatedShutdown)
-> no failover, total outage. Reproduced twice. Confirms #459 is REAL and
disproves Option A (keep keep-oldest). Baseline ServiceLevel 250(leader)/240(follower)
captured before the kill. Documents why (textbook 2-node keep-oldest limitation;
down-if-alone doesn't rescue a crashed oldest) and why the in-process
HardKillFailoverTests gave false confidence (Transport.Shutdown keeps the
ActorSystem alive, not a real crash). Fix needs Option B (witness+keep-majority)
or Option C (DB-lease arbiter).
2026-07-15 10:03:31 -04:00
dohertj2 b364a499e5 Merge pull request 'test+docs(resilience): prove authored ResilienceConfig reaches the runtime pipeline (#456)' (#461) from fix/456-resilience-config-artifact-roundtrip into master
v2-ci / build (push) Successful in 4m22s
v2-ci / unit-tests (push) Failing after 10m28s
2026-07-15 08:49:51 -04:00
Joseph Doherty 0c005c9a72 docs(archreview): live proof authored ResilienceConfig reaches the runtime pipeline (#456)
v2-ci / build (pull_request) Successful in 4m17s
v2-ci / unit-tests (pull_request) Failing after 10m27s
Captures the docker-dev live verification for follow-up #10 acceptance item 2:
a non-default per-instance ResilienceConfig (Subscribe.retryCount:999) authored on
MAIN rode the deploy artifact into BOTH central-1/central-2 runtimes, which parsed
and clamped it — the authored value appears verbatim in the spawn-time diagnostic.
Confirms the artifact bytes carry ResilienceConfig and the runtime pipeline applies
authored (not tier-default) policy. Item 1 (raw retry/breaker-on-fault log line)
remains, with the connect-then-idempotent-write recipe documented.
2026-07-15 08:49:17 -04:00
Joseph Doherty 809e7886d8 test(resilience): prove ResilienceConfig survives the real ConfigComposer round-trip (#456)
The arch-review #10 sub-gap worried that per-instance ResilienceConfig never
reaches the runtime pipeline. The threading is in fact wired end-to-end (task #13):
ConfigComposer serialises the whole DriverInstance entity, so ResilienceConfig
rides the artifact into DriverInstanceSpec, which DriverHostActor layers onto the
driver's Polly pipeline. The only leg with no test was the real composer->artifact
serialization — the existing DeploymentArtifactTests hand-build the JSON.

Adds ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip
(mirrors the DeviceHost round-trip guard): seeds a DriverInstance WITH a non-default
override + one WITHOUT, runs the real SnapshotAndFlattenAsync, and asserts
ParseDriverInstances recovers the override byte-for-byte (and null stays null).
Guards against a future projection / [JsonIgnore] silently reverting authored
resilience policy to tier defaults while hand-built artifact tests stay green.

ControlPlane.Tests ConfigComposerTests 6/6 green.
2026-07-15 08:38:13 -04:00
dohertj2 5908339d67 Merge pull request 'fix(deps): clear CVE-2025-6965 — pin SQLitePCLRaw bundle to 2.1.12, drop audit suppression' (#460) from fix/458-sqlitepclraw-cve-unsuppress into master
v2-ci / build (push) Successful in 4m28s
v2-ci / unit-tests (push) Failing after 10m45s
2026-07-15 08:17:55 -04:00
Joseph Doherty b461b2bcf8 fix(deps): clear CVE-2025-6965 — pin SQLitePCLRaw bundle to 2.1.12, drop audit suppression
v2-ci / build (pull_request) Successful in 5m41s
v2-ci / unit-tests (pull_request) Failing after 10m49s
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
2026-07-15 08:17:19 -04:00
Joseph Doherty e0ff908d26 docs(infra): fix memory pointers rendering as broken shortcut-links
v2-ci / build (push) Successful in 5m4s
v2-ci / unit-tests (push) Failing after 12m19s
The [reference_*] pointers target agent memory (not repo files) and rendered as
undefined markdown shortcut-reference links; converted to plain code spans. All
repo-relative links + paths in infra/README.md verified to resolve.
2026-07-15 08:06:54 -04:00
Joseph Doherty 09066eb88f docs(archreview): file residual follow-ups as Gitea issues #456-#459
v2-ci / build (push) Successful in 4m29s
v2-ci / unit-tests (push) Failing after 13m3s
The 4 non-blocking residuals (#10 live-gate/ResilienceConfig sub-gap, R2-11 Phase C
strict parsers, CVE-2025-6965 suppression removal, SBR oldest-node failover gap) are
now tracked as Gitea issues, cross-referenced in the STATUS.md capstone.
2026-07-15 08:02:24 -04:00
Joseph Doherty 1d3b6d1264 docs(infra): add infra/ folder — canonical testing + deployment infra index
v2-ci / build (push) Successful in 4m4s
v2-ci / unit-tests (push) Failing after 12m22s
Consolidates the scattered infra facts (docker host layout, driver fixtures, the
three env-gated live-gate recipes, the Host.IntegrationTests harness, the HistGW
local deploy, docker-dev rig, shared GLAuth, deploy) into infra/README.md as the
single entry point, cross-linked to dev-environment.md + the deep docs. CLAUDE.md
points at it from the Docker Workflow section.
2026-07-15 08:00:37 -04:00
Joseph Doherty c9cf5015e5 docs(archreview): R2-08 S3 gateway LiveIntegration soak GREEN — all Round-2 live gates closed
v2-ci / build (push) Successful in 5m15s
v2-ci / unit-tests (push) Failing after 11m9s
Ran Category=LiveIntegration 6/6 against the running local 0.2.0 HistorianGateway
(localhost:5221 h2c) + the real AVEVA historian over VPN. Confirms R2-08 S3's
GatewayHistorianDataSource read path and re-confirms R2-06. With R2-01 + R2-08
GLAuth already self-serviced, every Round-2 live gate is now GREEN.
2026-07-15 07:50:56 -04:00
Joseph Doherty 7bca0eeca0 docs(archreview): R2-08 GLAuth outage/recovery live gate CLOSED (PR #455)
v2-ci / build (push) Successful in 5m38s
v2-ci / unit-tests (push) Failing after 12m34s
LdapGlauthOutageLiveTests drives the S2 directory-outage circuit through the
production auth path against a real GLAuth (docker pause/unpause) on 10.100.0.35.
Only R2-06 VPN + R2-08 S3 LiveIntegration gateway soak remain (both need VPN).
2026-07-15 07:46:39 -04:00
dohertj2 5f12636cbc Merge pull request 'test(ldap): live GLAuth outage/recovery gate (R2-08 03/S2)' (#455) from test/r2-08-glauth-outage-live-gate into master
v2-ci / build (push) Successful in 5m33s
v2-ci / unit-tests (push) Failing after 11m32s
2026-07-15 07:45:24 -04:00
Joseph Doherty 4d2dcff5df test(ldap): live GLAuth outage/recovery gate (R2-08 03/S2)
v2-ci / build (pull_request) Successful in 3m59s
v2-ci / unit-tests (pull_request) Failing after 11m55s
Closes the R2-08 S2 live leg. The offline LdapAuthResilienceTests prove the
directory-outage circuit with fakes + an unroutable-host blackhole; this drives
the SAME circuit through the production auth path (OtOpcUaLdapAuthService +
LdapOpcUaUserAuthenticator) against a REAL GLAuth that is paused mid-run and
unpaused — and doubles as the first end-to-end verification that the PR #451
GLAuth swap (retired bitnami/openldap) binds correctly.

Triple-gated (GLAUTH_LIVE_HOST + GLAUTH_OUTAGE_START_CMD/STOP_CMD), skips clean
offline (verified: 1 skipped, 8 ms). try/finally always unpauses GLAuth.

Verified GREEN against 10.100.0.35:3894 (12 s): healthy binds (alice+bob, mapped
roles) -> docker pause -> 3 bounded failures -> circuit opens -> sub-second
"unavailable" fast-deny -> docker unpause -> half-open probe closes -> binds
succeed again. A boundary AuthTimeout feeds the circuit (authenticator :123-130),
so the docker-pause timeout shape is a faithful outage.
2026-07-15 07:45:07 -04:00
dohertj2 a19c6b987c Merge pull request 'fix(drivers): harden operator TimeoutMs handling + AbLegacy evict parity' (#454) from fix/driver-timeout-hardening into master
v2-ci / build (push) Successful in 4m12s
v2-ci / unit-tests (push) Failing after 10m27s
2026-07-15 07:35:37 -04:00
Joseph Doherty a5a0f96d49 fix(drivers): harden operator TimeoutMs handling + AbLegacy evict parity
v2-ci / build (pull_request) Successful in 4m5s
v2-ci / unit-tests (pull_request) Failing after 10m45s
Follow-ups from the fleet-wide read-timeout audit that the S7 R2-01 read-leg
fix (PR #453) prompted. The audit confirmed S7 was the ONLY driver with the
async-read-ignores-socket-timeout hang; these are the two adjacent (non-hang)
findings it surfaced.

1. FOCAS TimeoutMs:0 footgun (the risky one): a non-positive Timeout made
   SynchronizedFocasClient DISABLE its per-call wall-clock ceiling, reverting to
   the caller's long-lived poll token — reintroducing exactly the frozen-peer
   wedge S7 just eliminated, under misconfig. Clamp non-positive TimeoutMs to the
   2s default at the config boundary so the deadline can never be authored away.

2. TimeoutMs validation symmetry: apply the same clamp in the AbCip + AbLegacy
   factories. libplctag's Tag.Timeout setter throws on <=0, faulting tag creation
   on every read/write; clamping keeps a misconfigured TimeoutMs:0 running on the
   default bound instead. (Shared PositiveTimeoutOrDefault helper per factory.)

3. AbLegacy reconnect parity with AbCip: AbLegacy evicted the cached libplctag
   runtime on neither the non-zero-status nor transport-exception read/write path
   (AbCip evicts on both), so a data-path fault recovered only via the probe loop
   / libplctag internals. Added EvictRuntime + wired it into both read and write
   failure paths so a fresh handle is created on the next call.

Tests: FOCAS 265->269 (clamp theory + positive), AbCip 336->339 (clamp theory +
positive), AbLegacy 209->212 (read-nonzero / read-exception / write-nonzero evict).
No production regressions; all three driver suites green.
2026-07-15 07:35:17 -04:00
Joseph Doherty c21e21423f docs(archreview): R2-01 S7 live gate CLOSED — read-leg wall-clock fix (PR #453)
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 12m57s
Running S7_1500ConnectTimeoutOutageTests (docker-pause blackhole, self-serviceable
over SSH) uncovered + fixed the read-leg wall-clock gap STAB-14 left. Updates the
R2-01 STATUS row, the needs-user/still-open lists, and the STAB-14 remediation note.
2026-07-15 07:13:46 -04:00
dohertj2 4dc937fcb8 Merge pull request 'fix(s7): bound async wire ops with a wall-clock deadline (R2-01 read leg)' (#453) from fix/r2-01-s7-read-wallclock-timeout into master
v2-ci / build (push) Successful in 5m10s
v2-ci / unit-tests (push) Failing after 14m39s
2026-07-15 07:12:14 -04:00
Joseph Doherty 88e0977ae6 fix(s7): bound async wire ops with a wall-clock deadline (R2-01 read leg)
v2-ci / build (pull_request) Successful in 5m5s
v2-ci / unit-tests (pull_request) Failing after 13m50s
The R2-01 live gate (S7_1500ConnectTimeoutOutageTests, docker-pause blackhole)
surfaced a real gap the offline fakes couldn't: S7.Net's ReadTimeout/WriteTimeout
map to the TcpClient's ReceiveTimeout/SendTimeout, which govern only SYNCHRONOUS
socket calls — the async read/write paths S7.Net uses ignore them. On an
established-but-frozen peer (frozen PLC / firewall DROP / cable pulled mid-flow,
TCP session still open) a read blocked until the OS TCP stack gave up (minutes),
silently wedging the poll loop: no Bad tick, no reconnect. STAB-14 fixed only the
CONNECT leg (EnsureConnectedAsync CancelAfter); this is its READ-leg sibling.

- New S7OperationDeadline: bounds every data-plane wire op with a wall-clock
  ceiling (= _options.Timeout), surfacing an overrun as TimeoutException. Applied
  in S7PlcAdapter to Read/ReadBytes/Write/WriteBytes/ReadStatus. OpenAsync is left
  to EnsureConnectedAsync's own CancelAfter (not double-bounded).
- IsS7ConnectionFatal now classifies TimeoutException fatal → handle marked dead →
  next EnsureConnectedAsync reopens (connect-timeout fix takes over from there).
- Tests: 5 S7OperationDeadline unit tests (deadline / token-honouring / caller-
  cancel-passthrough / resultless), 1 driver-reaction test (read TimeoutException
  → reopen). Driver.S7.Tests 260/260.
- Live gate S7_1500ConnectTimeoutOutageTests now GREEN against the real snap7 sim
  (8s: baseline Good -> pause blackhole -> Bad tick -> unpause -> recovered Good).
2026-07-15 07:11:42 -04:00
dohertj2 152a5645ad fix(harness): deploy E2E tests pass against real SQL (#452)
v2-ci / build (push) Successful in 4m28s
v2-ci / unit-tests (push) Failing after 10m2s
2026-07-15 06:43:37 -04:00
Joseph Doherty 859d63178a fix(harness): make deploy E2E tests pass against real SQL (seed cluster FKs)
v2-ci / build (pull_request) Successful in 4m23s
v2-ci / unit-tests (pull_request) Failing after 10m14s
Integration-sweep follow-up #6 (SQL leg). The sweep flagged 7 heavy 2-node
deploy/failover/reconnect E2E tests as 'time out under amd64-emulated SQL' and
suggested raising deadlines. That diagnosis was WRONG: run against real
(native-amd64) SQL on the Docker host, they still hang — even at 4x deadline.
Root cause is that these tests had never actually run against real SQL, only the
EF in-memory provider, which does NOT enforce foreign keys.

Two distinct defects, both hidden by in-memory:

1. Missing FK-parent seed (5 tests). The deploy records a NodeDeploymentState row
   per node, FK'd to ClusterNode (FK_NodeDeploymentState_ClusterNode_NodeId). The
   tests never seeded ServerCluster + ClusterNode, so on SQL each node-state INSERT
   throws and the deploy never seals (waits the full deadline regardless of size).
   Added TwoNodeClusterHarness.SeedDefaultClusterAsync (seeds a ServerCluster +
   a ClusterNode per node; Warm/NodeCount=2 to satisfy the SQL CHECK constraint
   CK_ServerCluster_RedundancyMode_NodeCount AND the ClusterEnabledNodeCountMismatch
   validator). Called it in DeployHappyPath x2 / Failover / FleetDiagnostics /
   EquipmentNamespace; fixed DriverReconnect to seed node B + NodeCount=2.

2. Stale EquipmentId (EquipmentNamespace test; pre-existing on BOTH providers).
   Seeded EquipmentId='eq-1', but the later-added EquipmentIdNotDerived validator
   requires EquipmentId == DeriveEquipmentId(EquipmentUuid) ('EQ-'+first 12 hex).
   Fixed the seed to a canonical id + matching UUID. Only surfaced once the FK fix
   let the deploy reach equipment validation.

Also added OTOPCUA_HARNESS_SQL_HOST / OTOPCUA_HARNESS_LDAP_HOST overrides so the
harness fixtures can run on the native-amd64 Docker host (avoids arm64 mssql
emulation entirely).

Verified: the 5 SQL-backed classes go 11/11 green vs native SQL; in-memory default
unregressed (12/12). RoslynVirtualTagEvaluatorTests racing test is a pre-existing
flaky race (not SQL-backed; passes in isolation) — left as-is.
2026-07-15 06:43:15 -04:00
dohertj2 779afbb66f fix(harness): swap retired bitnami/openldap for GLAuth (#451)
v2-ci / build (push) Successful in 4m0s
v2-ci / unit-tests (push) Failing after 9m29s
2026-07-15 06:04:17 -04:00
Joseph Doherty 3a48eb0c70 fix(harness): swap retired bitnami/openldap for GLAuth in the Host real-LDAP mode
v2-ci / build (pull_request) Successful in 4m13s
v2-ci / unit-tests (pull_request) Failing after 10m18s
The Host.IntegrationTests opt-in real-LDAP mode (OTOPCUA_HARNESS_USE_LDAP=1)
used bitnami/openldap:2.6, gone since Bitnami deprecated their free image
catalog in 2025. Replaced it with GLAuth — the same LDAP server the rest of
the project already uses (docker-dev + the live OPC UA data-plane auth, both
against the shared GLAuth on :3893) — removing the lone OpenLDAP outlier and
the gone-image breakage.

- Added tests/.../Host.IntegrationTests/glauth/config.toml: baseDN dc=zb,dc=local,
  a search-capable serviceaccount, dev users alice (full access) / bob (read-only),
  and role groups with the same gidnumbers as scadaproj/infra/glauth so GroupToRole
  maps identically.
- Compose ldap service -> glauth/glauth:latest, mounting the config, host :3894 -> :3893.
- Repointed the harness real-LDAP override from the OpenLDAP cn=admin/ldapadmin to
  GLAuth's cn=serviceaccount/serviceaccount123.

Live-verified against a deployed container on :3894: the serviceaccount bind
searches and returns alice + her 5 memberOf groups; alice/bob bind with the
correct password; a wrong password yields Invalid credentials (49) — exactly the
OtOpcUaLdapAuthService flow (proven identical to the data-plane's shared-GLAuth path).

NB: real-LDAP mode is opt-in; the default Host run uses StubLdapAuthService, so
this never blocked the default suite. Integration-sweep follow-up #6 (LDAP leg);
the amd64-emulated-SQL deadline leg remains open.
2026-07-15 06:03:50 -04:00
dohertj2 18481f3d11 fix(focas-tests): honour fixture endpoint + fix mock timer endianness (#450)
v2-ci / build (push) Successful in 4m34s
v2-ci / unit-tests (push) Failing after 10m37s
2026-07-15 05:52:03 -04:00
Joseph Doherty 7982043673 fix(focas-tests): honour fixture endpoint + fix mock timer endianness (9F/1P -> 10/10)
v2-ci / build (pull_request) Successful in 4m31s
v2-ci / unit-tests (pull_request) Failing after 10m26s
Two test/fixture bugs; NO OtOpcUa driver change.

1. Topology: FocasSimFixture reads an env-overridable endpoint
   (OTOPCUA_FOCAS_SIM_ENDPOINT, default localhost:8193) and exposes Host/Port,
   but WireBackendTests + WireBackendCoverageTests hardcoded
   focas://127.0.0.1:8193. Against a remote fixture the skip-gate passed
   (remote reachable) but the driver dialed localhost -> KeyNotFound. Added a
   DeviceUri property to the fixture; each test now derives DeviceHost from it.
   Recovered 8 of 9.

2. Mock timer endianness (surfaced once #1 let the tests reach the mock): the
   last failure read 60397977600 for a 3600 s power-on timer (= 0x3C000000 x 60).
   The focas-mock's _wire_timer encoded the minute/msec fields big-endian (_u32),
   but cnc_rdtimer's timer fields are LITTLE-endian on real hardware — a
   documented, live-31i-B-validated quirk that FocasWireClient.ParseTimer decodes
   LE (docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md). The driver was
   correct; the mock was wrong. _wire_timer now emits little-endian (_u32_le).

Suite 9F/1P -> 10/10 against the remote fixture at ~/otopcua-focas.
Integration-sweep follow-up #3.
2026-07-15 05:51:47 -04:00
dohertj2 00d8422cef docs(sweep): AbLegacy verified green — fixture-deployment gap, no code (#449)
v2-ci / build (push) Successful in 4m28s
v2-ci / unit-tests (push) Failing after 11m5s
2026-07-15 05:38:37 -04:00
Joseph Doherty f381b471f9 docs(sweep): AbLegacy verified green — fixture-deployment gap, not a code defect (#4)
v2-ci / build (pull_request) Successful in 4m26s
v2-ci / unit-tests (pull_request) Failing after 3h11m47s
The sweep's AbLegacy 4F was a fixture-deployment gap, not a driver/test defect.
The suite needs ab_server in PCCC mode (--plc=SLC500|Micrologix|PLC/5), which
reuses the AbCip image (already on the host) and binds :44818. No PCCC container
was deployed, and the 4F-not-skip means :44818 was answering CIP (a leftover
AbCip container) so the PCCC reads returned Bad — same shared-port contamination
class as the Modbus false alarm.

Deployed the PCCC compose to ~/otopcua-ablegacy and ran each family with
AB_LEGACY_COMPOSE_PROFILE set to match the running container (required — without
it the [Theory] parametrises over all 3 families and 2 fail against a single-
family container): slc500 2/2, micrologix 1/1 (+1 skip), plc5 1/1 (+1 skip).
Skips are family-inapplicable file types. No code change.

Corrected the sweep record + added the AbLegacy bring-up + run recipe.
Integration-sweep follow-up #4.
2026-07-15 05:38:18 -04:00
dohertj2 ab1644daa4 docs(modbus-tests): sweep #2 was a fixture-cycling false alarm (#448)
v2-ci / build (push) Successful in 4m19s
v2-ci / unit-tests (push) Failing after 10m47s
2026-07-15 05:13:12 -04:00
Joseph Doherty 00f39c849e docs(modbus-tests): document profile-cycling gotcha; sweep #2 was a false alarm
v2-ci / build (pull_request) Successful in 4m13s
v2-ci / unit-tests (pull_request) Failing after 9m41s
The 2026-07 integration sweep flagged the Modbus non-standard profiles as
8F/5F/3F/8F and guessed 'pymodbus 4.x drift'. Re-investigation shows that was
wrong on both counts:

- The image runs the PINNED pymodbus 3.13.0 (verified via import pymodbus;
  __version__). The '...removed in v4' log is 3.13.0's forward-deprecation
  warning, misread as running 4.x.
- The failures were a fixture-cycling artifact: each profile is a separate
  profile-gated compose service sharing :5020, and a plain 'docker compose down'
  (no --profile) does NOT stop the running profile container. It kept holding
  :5020; the next '--profile up' container silently failed to bind (compose
  still says 'Started'), so every later profile run hit the STALE sim. Proven by
  a raw Modbus probe: the 'mitsubishi' sim served DL205's 0xCAFE at HR0.

After force-removing all modbus containers and bringing up each profile cleanly
(verifying it actually published :5020), all four pass: dl205 16/16,
mitsubishi 13/13, s7_1500 10/10, exception_injection 17/17. No OtOpcUa or test
code change needed.

Added a profile-cycling gotcha + reliable-cycle recipe to Docker/README.md and
corrected the sweep record. Integration-sweep follow-up #2.
2026-07-15 05:12:52 -04:00
dohertj2 271562e00e fix(opcuaserver-tests): fix cert-store gap + SDK read-throws drift (#447)
v2-ci / build (push) Successful in 7m17s
v2-ci / unit-tests (push) Failing after 12m55s
2026-07-15 03:34:35 -04:00
Joseph Doherty de9d9697fd fix(opcuaserver-tests): supply client cert-store paths + adapt to SDK read-throws
v2-ci / build (pull_request) Successful in 7m23s
v2-ci / unit-tests (pull_request) Failing after 15m33s
OpcUaServer.IntegrationTests was 4F. Two distinct, previously-masked issues:

1. Cert-store gap: the in-test client SecurityConfiguration used a bare
   new SecurityConfiguration()/CertificateIdentifier(), so ValidateAsync threw
   'TrustedIssuerCertificates StorePath must be specified' before any connect.
   Added TestClientSecurity helper building Directory own/issuer/trusted/rejected
   stores under a throwaway temp PKI dir (mirrors DefaultApplicationConfigurationFactory);
   wired into DualEndpointTests + SubscriptionSurvivalTests.

2. Fixing #1 unmasked an SDK-version drift: the 1.5.378 node-cache read path
   throws ServiceResultException(BadNodeIdUnknown) for a removed node instead of
   returning a bad DataValue. The two subscription-survival assertions now expect
   the throw via Should.ThrowAsync. Behavior under test (removed node -> unknown)
   is unchanged; only the delivery mechanism differs.

Suite 4F -> 4/4 green. Integration-sweep follow-up #5.
2026-07-15 03:34:14 -04:00
dohertj2 c1482802d4 fix(abcip-tests): fix static-init NRE blocking AbCip.IntegrationTests (#446)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 18m20s
2026-07-15 03:29:03 -04:00
Joseph Doherty 2ba867b0f8 docs(archreview): mark integration-sweep follow-up #1 (AbCip static-init) done
v2-ci / build (pull_request) Successful in 4m19s
v2-ci / unit-tests (pull_request) Failing after 17m55s
2026-07-15 03:28:24 -04:00
Joseph Doherty 326b22a7ba fix(abcip-tests): expression-body KnownProfiles.All to fix static-init NRE
KnownProfiles.All used a { get; } = [...] initialiser declared textually
before the static readonly profile fields it references, so C# static-init
order captured an all-null list. ForFamily() then NRE'd on p.Family, faulting
AbServerFixture..ctor and blocking the whole AbCip.IntegrationTests suite.

Expression-body it so it evaluates on access, after the fields are set.
Live-verified against the controllogix fixture: suite goes 6F/1P/3skip ->
10 pass / 0 fail / 3 skip (skips = emulator-mode tests).

Integration-sweep follow-up #1 (archreview/plans/INTEGRATION-SWEEP-STATUS.md).
2026-07-15 03:28:04 -04:00
dohertj2 b94ea3d412 Merge pull request 'docs(archreview): integration-suite sweep results + follow-ups' (#445)
v2-ci / build (push) Successful in 4m50s
v2-ci / unit-tests (push) Failing after 9m2s
2026-07-15 01:26:14 -04:00
Joseph Doherty 1ee73d76d6 docs(archreview): record integration-suite sweep results + follow-ups (2026-07-14)
v2-ci / build (pull_request) Successful in 4m46s
v2-ci / unit-tests (pull_request) Failing after 10m56s
Ran the deferred *.IntegrationTests sweep (Mac + docker-host fixtures,
serial). No OtOpcUa regression: OpcUaClient(+Browser)/S7/Modbus-standard
green, Host 105/117, TwinCAT clean-skip. The rest fail on pre-existing
test/fixture bit-rot of distinct kinds (AbCip static-init bug, Modbus
pymodbus-4.x sim drift, FOCAS 127.0.0.1-mock topology, AbLegacy no
fixture, OpcUaServer cert-store config, Host openldap:2.6 image gone).

New INTEGRATION-SWEEP-STATUS.md has the full table, per-suite evidence,
7 follow-ups, and re-run fixture recipes; STATUS.md links it.
2026-07-15 01:25:55 -04:00
dohertj2 306728885d Merge pull request 'docs(archreview): R2-04 T13/T15 live-passed' (#444)
v2-ci / build (push) Successful in 4m13s
v2-ci / unit-tests (push) Failing after 10m54s
2026-07-14 15:14:05 -04:00
Joseph Doherty eccb15bdab docs(archreview): R2-04 T13/T15 live-passed 2026-07-14
v2-ci / build (pull_request) Successful in 4m8s
v2-ci / unit-tests (pull_request) Failing after 11m0s
Both R2-04 verification gates for the S4 primary-gate default-deny run:
- T15 (docker-dev 2-node rig, rebuilt from master 365a90e6 + Modbus sim):
  ServiceLevel 250/240; boot-window write rejected 'not primary (role
  unknown)' (meter reason=role-unknown,site=write); steady-state
  secondary rejected 'not primary' + node reverted (read-back 1234 not
  9999, meter reason=secondary); primary write reaches device; clean
  apply (no apply.failed). The behavior-affecting S4 slice is trusted.
- T13 (in-process PrimaryGateFailoverTests): 1/1, ran without SQL; the
  real DPS-delivered snapshot drives the gate; negative control covered.

STATUS.md + R2-04 tasks.json updated.
2026-07-14 15:13:43 -04:00
dohertj2 365a90e63d Merge pull request 'fix(s7-cli): restore OnDataChange CliFx-console rationale comment' (#443)
v2-ci / build (push) Successful in 3m17s
v2-ci / unit-tests (push) Failing after 8m2s
2026-07-14 14:49:26 -04:00
Joseph Doherty d327243fd3 fix(s7-cli): restore OnDataChange CliFx-console rationale comment
v2-ci / build (pull_request) Failing after 15s
v2-ci / unit-tests (pull_request) Has been skipped
The 9cad9ed0 'strip tracking-ID comments' XML-doc sweep collaterally
removed the comment explaining why the S7 subscribe command routes
OnDataChange through the CliFx IConsole (not System.Console), leaving
SubscribeCommandConsoleHandlerCommentTests red. The Modbus copy still
carries the comment verbatim and the test guards S7-mirrors-Modbus
parity, so restore it (matching the Modbus wording) rather than weaken
the guard.

S7.Cli tests 49/49.
2026-07-14 14:49:10 -04:00
dohertj2 38e21df296 Merge pull request 'fix(historian): map Boolean historization to Int2, not degenerate Int1 (#441)' (#442)
v2-ci / build (push) Successful in 3m24s
v2-ci / unit-tests (push) Failing after 8m26s
2026-07-14 12:38:04 -04:00
Joseph Doherty 9f62310b49 fix(historian): map Boolean historization to Int2, not degenerate Int1 (#441)
v2-ci / build (pull_request) Successful in 3m24s
v2-ci / unit-tests (pull_request) Failing after 8m19s
The historian's Int1 analog-tag creation path is server-degenerate:
EnsureTags(Int1) stores an unusable stub (live-probed on the deployed
2023 R2 historian; root-caused + documented gateway-side in
HistorianGateway #11 / PR #16). HistorianTypeMapper mapped
DriverDataType.Boolean -> HistorianDataType.Int1, so Boolean historized
tags failed auto-provisioning.

Map Boolean -> Int2 instead (store 0/1). Int2 is a proven-creatable
analog type, and the value-write path already sends every value as a
double on WriteLiveValues regardless of tag type, so Boolean 0.0/1.0
round-trips cleanly. Both Float and Int2 are supported, so a
pre-existing Float Boolean tag retypes cleanly on its next deploy.

- HistorianTypeMapper: Boolean => Int2 (+ remarks)
- Unit pins: HistorianTypeMapperTests, GatewayTagProvisionerTests
  (Boolean_definition_carries_explicit_Int2_presence)
- Live gate: EnsureTags_boolean_sandbox_provisions_as_Int2 (was _Int1);
  retype probe reframed Float->Int2 (drops the dead Int1 leg)
- Docs: docs/Historian.md 'Boolean historization maps to Int2', CLAUDE.md,
  archreview STATUS.md (R2-06 live gate -> 6/6)

Resolves #441.
2026-07-14 11:57:27 -04:00
dohertj2 7f79cd5941 Merge pull request 'deps: bump HistorianGateway Client+Contracts to 0.3.0' (#440) from bump-historiangateway-0.3.0 into master
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 12m24s
2026-07-14 08:18:07 -04:00
Joseph Doherty 037d8d3c57 deps: bump ZB.MOM.WW.HistorianGateway Client+Contracts 0.2.0 -> 0.3.0
v2-ci / build (pull_request) Successful in 4m12s
v2-ci / unit-tests (pull_request) Failing after 11m59s
HistorianGateway 0.3.0 (ArchReview #2 cycle-2 cut). Restore + Gateway driver
build clean; driver tests 103/0. Only consumer behavioral delta is the client
HistorianGatewayClientOptions.StreamTimeout default (null -> 10 min); OtOpcUa's
Gateway driver does not require unbounded streaming reads (set StreamTimeout=null
if that ever changes). M-7 timestamp-semantics audit was already clean.
2026-07-14 08:17:47 -04:00
Joseph Doherty 9ba162d0f8 docs(archreview): cross-reference historiangw#11 for the Int1 EnsureTags gap
v2-ci / build (push) Successful in 3m49s
v2-ci / unit-tests (push) Failing after 8m10s
2026-07-14 05:35:15 -04:00
Joseph Doherty 416789c6ab docs(archreview): R2-06 live gate 5/6 + U-7 retype policy answered
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 8m44s
- StorageRateMs bug found+fixed (PR #439); live suite 3/6 -> 5/6
- U-7: EnsureTags is a real upsert; supported-type retype ACCEPTED (Float->Double);
  Int1 not creatable on this histsdk (ProtocolEvidenceMissingException, Int1-only)
- record the observed retype policy in docs/Historian.md (fills the placeholder)
2026-07-13 19:31:47 -04:00
Joseph Doherty 666908d792 Merge PR #439: stamp a positive StorageRateMs on provisioned tags
v2-ci / build (push) Successful in 4m14s
v2-ci / unit-tests (push) Failing after 8m47s
Fixes the historized-tag provisioning path that silently failed at the gateway
(StorageRateMs=0 -> ArgumentOutOfRangeException). Found via the R2-06 live gate.
2026-07-13 19:30:06 -04:00
Joseph Doherty 4da2d62b9a fix(historian): stamp a positive StorageRateMs on provisioned tags
v2-ci / build (pull_request) Successful in 4m7s
v2-ci / unit-tests (pull_request) Failing after 10m50s
GatewayTagProvisioner built HistorianTagDefinition without a StorageRateMs, so
the proto uint32 defaulted to 0. The gateway's EnsureTags maps StorageRateMs
straight to the AVEVA SDK's storageRateMs, which requires a positive value —
0 makes the SDK throw ArgumentOutOfRangeException ('Storage rate must be > 0 ms'),
so EVERY historized-tag auto-provision (the deploy-time IHistorianProvisioning
hook) silently failed at the gateway with failed=N.

Stamp DefaultStorageRateMs=1000 (matches the gateway's own live provisioner
convention; quantized 1000/5000/10000). Add a regression unit test asserting the
definition carries a positive rate, and set StorageRateMs on the live-suite's
inline definitions so the retype probe reaches a real EnsureTags.

Found via the R2-06 live gate against a real 0.2.0 gateway + AVEVA historian:
with this fixed, Float/Int2/UInt2/Int4/UInt4/Double all provision and the live
suite goes 3/6 -> 5/6. (Int1/Boolean remains blocked by the gateway histsdk's
ProtocolEvidenceMissingException for Int1 — a gateway-side type-support gap, not
this bug.)

Gateway driver unit 103/103.
2026-07-13 19:29:49 -04:00