Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project
(greenfield — no CSV code existed).
CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable
overload + eager Parse(string) + thin ParseWithHeader. Handles quoted
fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space
preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict
malformed-input policy: FormatException with 1-based line/column on a
quote inside an unquoted field, a char after a closing quote, or an
unterminated quote. Faithful empty-line policy: a blank line is one empty
field (callers filter).
CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote,
CR, LF only; internal quotes doubled), configurable delimiter/newline
(default CRLF), optional quote-all, no trailing terminator.
Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the
writer quote-on-demand rules, and the required Parse(Write(rows)) == rows
round-trip property over a table of every tricky character (x CRLF/LF/
quote-all/semicolon).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Add DriverTypeNames as the single source of truth for driver-type
dispatch strings, one const per currently-registered driver factory
(value = the exact factory DriverTypeName). Fixes the latent drift
between hand-authored dispatch-map literals and the real factory names
(e.g. TwinCAT/FOCAS/GalaxyMxGateway) at the source.
A reflection guard test in Core.Abstractions.Tests builds the real
registered-factory set by driving each *DriverFactoryExtensions.Register
into a live DriverFactoryRegistry (mirroring the Host's
DriverFactoryBootstrap) and asserts bidirectional parity with the
constants, so any future rename on either side breaks the test gate.
Consumers are rewired in Batch 2 WP8; Calculation's const lands with its
factory in WP7.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
WP1 wrote these against its interim VirtualTag-only collision rule; WP4a replaced it
with the broader UnsEffectiveNameCollision rule. Neither test-sweep scope covered these
two Configuration.Tests files, so the assertions went stale. Configuration.Tests 107/107.
Fix TESTS only (production already green) so the four in-scope test projects
compile + pass under the v3 dark address space:
- Core.Abstractions.Tests: drop AllowedNamespaceKinds/NamespaceKindCompatibility
from DriverTypeMetadata; rewrite EquipmentTagRefResolver tests to the single-func
RawPath-lookup contract (no parseRef/transient cache).
- Core.Tests: rewrite EquipmentNodeWalkerTests to EquipmentNamespaceContent(Areas,
Lines,Equipment,VirtualTags?,ScriptedAlarms?) — folders + VirtualTags +
ScriptedAlarms only; raw-tag variables dark (skipped Batch-4 placeholder). Drop
retired Equipment.DriverInstanceId.
- Runtime.Tests: rewrite golden corpus + TagConfigCorpusParityTests to the v3
RawPath/RawTagEntry round-trip (byte + TagConfigIntent parity; EquipmentTags dark).
Salvage VirtualTag + ScriptedAlarm artifact parity to the new Compose signature.
Retire the equipment-tag-materialization parity files (Array/Historize/Alias) and
the equipment-device-binding DeviceHost parity to documented skipped placeholders.
Skip 34 dark equipment-tag routing/value/alarm/write/discovery actor tests with a
shared DarkAddressSpaceReasons constant. Re-key cluster-scoped tests to v3 UNS
line->area attribution.
- ControlPlane.Tests: ConfigComposer round-trips re-keyed to RawFolder/Device (no
Namespaces); tag-config gate resolves driver via Device; deploy-gate collision test
re-keyed to UnsEffectiveNameCollision; drop retired BadCrossClusterNamespaceBinding
case.
Replace pre-declared options.Tags (typed <Driver>TagDefinition lists) with
options.RawTags (IReadOnlyList<RawTagEntry>) across all 6 Docker-gated driver
integration suites so they COMPILE against the v3 driver API.
Each project gets a local <Driver>RawTags helper mirroring the unit-test
project's helper: it serialises a typed def back to its TagConfig blob via the
driver's <Driver>TagDefinitionFactory.ToTagConfig and packages it as a
RawTagEntry (RawPath = def.Name; DeviceName = def.DeviceHostAddress for the
multi-device drivers AbCip/AbLegacy/TwinCAT/FOCAS). Test intent (addresses,
data types, device routing, assertions) is unchanged — only the
tag-authoring/config-delivery shape was migrated.
Docker-gated: these still skip at runtime without fixtures; COMPILE is the gate.
- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
(DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
(single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
historized effective-tagname <=255, UNS effective-leaf uniqueness across
UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.
TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
Wave-B driver re-keying. Under v3 the server addresses Galaxy nodes by RawPath
and the driver dials the Galaxy attributeRef (tag_name.AttributeName). The
dialled address moved into TagConfig under the camelCase key `attributeRef`
(renamed from the pre-v3 PascalCase FullName) and rides on RawTagEntry.
Options + factory:
- GalaxyDriverOptions gains `IReadOnlyList<RawTagEntry> RawTags` (Contracts now
references the zero-dep Core.Abstractions leaf for RawTagEntry — same as
Modbus.Contracts). Factory binds `List<RawTagEntry>? RawTags` from DriverConfig.
Boundary translation (built once from options.RawTags):
- RawPath -> attributeRef (forward dial) on Read / Write / Subscribe / Ack.
- attributeRef -> RawPath (reverse) on the value fan-out (OnDataChange) and the
alarm ConditionId — so the alarm resolves back to the same RawPath the value
path surfaces. Both legs fall back to identity on a miss, so the live-browse
discovery path (no authored tags) and synthetic alarm sub-refs
(attributeRef.InAlarm) dial straight through unchanged.
- WriteAsync dials the attributeRef but resolves security via the RawPath-keyed
map the discoverer captured (reverse-maps in the resolver closure).
Discovery + alarms:
- GalaxyDiscoverer emits FullName = RawPath for authored attributes (falls back
to attributeRef), threads RawTagEntry.WriteIdempotent onto DriverAttributeInfo,
and gains an optional attributeRef -> RawTagEntry resolver ctor param.
- AlarmRefBuilder.Build(conditionReference, dialReference): SourceName/ConditionId
= RawPath identity; the five live-state/ack sub-refs dial off the attributeRef.
ReinitializeAsync equivalence now compares the session-shape sections field-wise,
excluding RawTags (address-space state re-applied via rediscovery, and a record's
synthesized equality would compare the lists by reference).
Tests: +9 (discoverer RawPath/WriteIdempotent + alarm identity; factory RawTags
binding; driver read/write/subscribe/alarm boundary translation). Existing 304
preserved via identity fallback. 313 pass, 5 live-gated skips.
NamespaceMap now carries a RawPath -> upstream node id table built from the
deployed RawTagEntry list (reads each tag's TagConfig.nodeId, threads
WriteIdempotent). Under v3 the read/write/subscribe/history reference handed to
the driver is the tag's RawPath identity; the driver resolves it in two stages:
RawPath -> nodeId string (instance TryResolve) -> live NodeId re-bound against
the session (static TryResolve). Alarm ConditionId + event-history sourceName
stay on the direct session parse (they are upstream node ids, not RawPaths).
- Options: add IReadOnlyList<RawTagEntry> RawTags (Contracts now refs Core.Abstractions).
- Factory: RawTags binds straight into options (no separate DTO); EndpointUrl kept.
- Browser: unchanged (emits neutral BrowseNode DTOs, no TagConfig FullName key).
- Tests: 138 green; new RawPath resolution + factory-binding coverage; migrated
the stale-session ReadRaw test to author a resolving RawTag.
Contracts + Driver + Browser build clean (0 warn). Wave C wires endpoint->DeviceConfig
and the deploy artifact that populates RawTags.
Rename S7EquipmentTagParser -> S7TagDefinitionFactory; TryParse(reference)
-> FromTagConfig(tagConfig, rawPath, out def) (drops the leading-{ heuristic,
keys the def by RawPath, adds inverse ToTagConfig). Replace S7DriverOptions.Tags
(pre-declared S7TagDefinition list) with RawTags (IReadOnlyList<RawTagEntry>);
the driver builds its RawPath->def table from RawTags at Initialize via the
factory (skip+log on miss), resolver is byName-only, DiscoverAsync + init guards
+ address pre-parse now run off that table. Factory retires the pre-declared DTO
tag path (RawTags binding only). Cli BuildOptions serialises typed defs to
RawTagEntry. Tests migrated to a S7RawTags helper + FromTagConfig; parser test
files renamed. Coordinator's EquipmentTagConfigInspector S7 rename left to Wave-B
coordinator; Driver.S7.IntegrationTests (Docker-gated) left red per scope.
Driver.S7 + Cli build green; Driver.S7.Tests 264/264, S7.Cli.Tests 49/49.
Wave-B EXEMPLAR. Retire the pre-declared/blob-parse tag model; the driver now
builds its RawPath -> definition table from the artifact's RawTagEntry set via a
pure mapper.
- Contracts: rename ModbusEquipmentTagParser -> ModbusTagDefinitionFactory;
TryParse(reference) -> FromTagConfig(tagConfig, rawPath) (def.Name = rawPath,
no leading-brace heuristic); add inverse ToTagConfig(def) serializer; keep all
strict-enum reads + guards; also read stringByteOrder/deadband/coalesceProhibited
so a RawTagEntry round-trips the full authored def. Inspect() unchanged.
- Options: Tags(IReadOnlyList<ModbusTagDefinition>) -> RawTags(IReadOnlyList<RawTagEntry>).
- Driver: _tagsByName -> _tagsByRawPath (Ordinal); resolver byRawPath-only; build
the table from RawTags at Initialize threading entry.WriteIdempotent onto each def;
Discover/Teardown updated.
- Factory: remove ModbusTagDto/BuildTag/ValidateStringLength + ConfigDto.Tags; bind
RawTags from driver-config JSON.
- CLI ModbusCommandBase.BuildOptions: serialise typed defs -> RawTagEntry via ToTagConfig.
- Tests: migrate Tags= -> RawTags via ModbusRawTags.Entries helper; parser tests ->
FromTagConfig(rawPath); factory String-length/enum tests re-seated on the mapper seam.
- Propagated rename to ControlPlane EquipmentTagConfigInspector (outside Modbus projects).
Modbus.Tests 315/315, Addressing.Tests 161/161, Cli.Tests 72/72 green.
Docker-gated Driver.Modbus.IntegrationTests left untouched (won't compile against
the new API; expected).
Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
the node set is non-empty and stable across two passes, bounded by open-timeout; on
timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver
(their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled
which PatchForBrowse guarantees at open). One gate fact per driver test project.
Task 1: ITagDiscovery.SupportsOnlineDiscovery default member (=> false)
Task 2: Commons -> Core.Abstractions ProjectReference
Task 13: TagModal BuildEditorParameters passes DriverType + GetDriverConfigJson;
all 7 typed tag editors declare the two new [Parameter]s (DynamicComponent
throws on unmatched params, so every editor must accept them).
R2-11 Phase C. All six equipment-tag parsers (Modbus/S7/AbCip/AbLegacy/
TwinCAT/Focas) now read enum fields via the new TagConfigJson.TryReadEnumStrict:
absent -> fallback, valid -> parsed, present-but-invalid (typo) -> TryParse
returns false -> EquipmentTagRefResolver.TryResolve false -> driver surfaces
BadNodeIdUnknown, instead of the old lenient path that silently defaulted a
typo to a wrong-width Good.
Modbus flips all three enum fields (region/dataType/byteOrder); the other five
flip dataType. The deploy-time Deployment:TagConfigValidationMode=Error gate is
unchanged and remains the operator pre-flight.
Coverage:
- Six *EquipmentTagParserStrictnessTests inverted from Freeze_typo_* (lenient)
to Typo_*_rejects_the_tag + Valid_*_still_parses.
- TagConfigJsonTests.TryReadEnumStrict_rejects_only_invalid matrix.
- Driver-level end-to-end proof:
AbCipEquipmentTagTests.Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown
drives the real AbCipDriver.ReadAsync through resolve->status.
Golden parity corpus has no typo'd enums, so the flip is a no-op there.
Full solution builds clean; all six driver suites + core + parity + gate green.
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.
Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.
Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
process exits (supervisor restarts it) instead of idling with a dead actor system.
Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
ANY single loss needs 3+ members.
Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
The 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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
Four continuous-historization types (IHistorizationOutbox, HistorizationOutboxEntry,
IHistorianValueWriter/HistorizationValue, HistorizationCommitMode) drifted into a
ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian sub-namespace while the other six files
in the same folder correctly use the root namespace. This violated decision #59's
flat-public-surface rule and left InterfaceIndependenceTests.AllPublicTypes_LiveInRootNamespace
red on master (pre-existing, predates round-2 remediation).
Move all four to the root namespace and drop/retarget the now-redundant
'using ...Core.Abstractions.Historian;' in the ~11 consumers. No behavioral change.
Verified: Core.Abstractions.Tests 129/129 (was 128/129), Runtime.Tests Historian+DI 72/72,
Gateway driver unit 102/102, full solution build 0 errors.