Compare commits

...

56 Commits

Author SHA1 Message Date
Joseph Doherty 47d148daf9 fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.

New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
  display never implies text that isn't there (the old markup appended "…"
  unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.

Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:

  Scripts.razor                    SourceHash    (the reported crash)
  Certificates.razor               Thumbprint    (read off the on-disk store)
  Deployments.razor  x2            RevisionHash
  Clusters/ClusterOverview.razor   RevisionHash

The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.

NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.

Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.

Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
2026-07-26 09:43:48 -04:00
Joseph Doherty 755ae1aa3f merge: re-assert non-normal scripted-alarm conditions after a (re)load (#487)
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 3h14m40s
Closes the redeploy-reset gap on Part 9 scripted-alarm condition nodes — the
alarm analogue of the VirtualTag ReassertValue fix.

A full address-space rebuild re-materialises every condition node fresh, while
the engine's reload produces EmissionKind.None for an alarm whose state did not
change — so nothing wrote the node. Live-reproduced on docker-dev: a cleared-
but-still-UNACKNOWLEDGED alarm came back reporting acknowledged, dropping Retain
to false, so it vanished from ConditionRefresh entirely. The operator's
outstanding alarm silently disappeared from the alarm list on deploy.

ScriptedAlarmHostActor.ReassertConditionNodes now writes one AlarmStateUpdate
per alarm not in the Part 9 no-event position, sourced from the new
ScriptedAlarmEngine.GetProjections(). Node-only by construction — the projection
type carries no EmissionKind, so it cannot become an alerts row or a duplicate
historian record.

Live gate PASSED (pre-fix image vs. rebuilt, central-2): condition absent
pre-fix, present + Unacknowledged + Retain=True post-fix, stamped with its own
LastTransitionUtc; /alerts empty across two re-asserts with the panel proven
live by a real ACTIVATED transition.
2026-07-26 09:18:23 -04:00
Joseph Doherty 549c656489 fix(alarms): re-assert non-normal scripted-alarm conditions after a (re)load (#487)
A deploy that triggers a FULL address-space rebuild clears `_alarmConditions`
and re-materialises every condition node fresh — inactive, acked, confirmed,
unshelved. The engine reloads its persisted state and re-derives Active from the
predicate, so it ends up correct; but there is no transition to report, so
`LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it. Nothing
writes the node.

Live-reproduced on the docker-dev rig against the pre-fix image: an alarm that
had fired and cleared but was still UNACKNOWLEDGED came back from a rebuild
reporting Acknowledged with Retain=false — so it disappeared from
ConditionRefresh entirely, while the engine still held Unacknowledged. An
operator's outstanding alarm silently vanishes from the alarm list on deploy.

`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load it
reads the new `ScriptedAlarmEngine.GetProjections()` and sends one
`AlarmStateUpdate` per alarm NOT in the Part 9 no-event position.

Load-bearing properties:

- Node-only. It sends `AlarmStateUpdate` and nothing else — never the `alerts`
  topic, never the telemetry hub. `alerts` is the historization path, so a row
  per alarm per deploy would append a duplicate historian/AVEVA record every
  time anyone deploys. This is why it reads a projection rather than re-emitting:
  `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for
  the emission machinery — or a future refactor — to mistake for a transition.
  The issue's sketch said `GetState(alarmId)` + `LoadedAlarmIds`; that alone
  would have clobbered the node's severity and message, so the projection also
  carries the definition's severity, the resolved message template, and the
  last-observed worst-input quality.
- Only non-normal alarms. One in the no-event position already matches what the
  materialise built, so a never-fired alarm stays byte-identical to a deploy
  without this pass (including its Message, which the materialise seeds with the
  display name), and an all-normal deploy writes nothing at all.
- Timestamp is the condition's own `LastTransitionUtc`, not the deploy instant.
- No duplicate Part 9 events: `WriteAlarmCondition`'s delta-gate compares against
  the node's CURRENT state, so a re-assert onto a freshly materialised node is a
  genuine delta (fires once — correct, the rebuild reset what clients see) while
  one onto a surgically-preserved node is suppressed.

Tests: 6 new host tests + 6 new engine tests. Falsifiability checked by
disabling the call — 4 of the 6 host tests go red; the other 2 are absence
guards against an over-broad fix and pass either way by design.

Live gate (docker-dev, central-2, pre-fix image vs. rebuilt image):
- pre-fix: condition absent from ConditionRefresh after a rebuild;
- post-fix: present, Unacknowledged, Retain=True, stamped with its own
  LastTransitionUtc rather than the restart instant;
- /alerts stayed empty across two re-asserts, with the panel proven live by a
  real ACTIVATED transition immediately afterwards — no duplicate history.
2026-07-26 09:06:39 -04:00
Joseph Doherty 28c2866710 merge: Sql driver follow-ups #496/#497/#498 + the Runtime.Tests flake #500 (fix/sql-driver-followups)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 15m14s
Four commits closing the three follow-ups the Sql poll driver left behind, plus the
intermittent Runtime.Tests failure found while verifying them.

- #497 corrects 16 wrong OPC UA status-code constants across 6 drivers (the issue named
  3) and adds StatusCodeParityTests, a reflection guard checking every driver's
  hard-coded uint against the pinned SDK. That guard is what found the other 13.
- #498 fails the deploy when a Sql driver's persisted config carries a literal
  connectionString — the write-side half of a guarantee only enforced on read.
- #496 implements the design 8.1 catalog gate: authored table/column names are resolved
  against the live catalog at Initialize and replaced with the catalog's own spelling, so
  quoting becomes the backstop it was documented to be rather than the sole defence.
- #500 fixes the Runtime.Tests flake: four ordering/logic defects plus a class of tight
  presence budgets, now routed through a documented RuntimeActorTestBase.PresenceBudget.

Verified: full solution builds, all 41 unit-test projects green, Sql integration suite
21/21 against the real SQL Server on 10.100.0.35, and 30 consecutive Runtime.Tests runs
clean against a measured 13% baseline.

Still open by design: #499 (ClusterNode.DriverConfigOverridesJson is a third config
persistence surface DraftValidator cannot see) and #501 (two alarm-ack tests use
AwaitAssert for an absence assertion, so they prove nothing; the rewrite may
legitimately turn them red).
2026-07-25 22:27:26 -04:00
Joseph Doherty 154171f48c test(runtime): fix the intermittent Runtime.Tests failure — 4 ordering/logic defects + the presence-budget class (#500)
The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test
name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it
was never ONE flaky test: five failures across three distinct tests in that batch, and
five more distinct tests over the verification rounds that followed.

Two hypotheses were tested and DISPROVED by measurement before anything was changed:

- Cluster-formation timeout. Every test in this assembly forms a real single-node Akka
  cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up
  — 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero
  timeouts. A 3.4x margin; not the cause.
- Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout.
  Not the cause.

FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout):

1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place
   The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and
   then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register,
   Unregister] interleaving LastSender is the DYING child and the assertion fails. Now
   captures the sender of the Register message as it arrives. Fully deterministic; no
   timing involved. (Swept the other four LastSender sites: all drain the Unregister
   first, so their ordering is fixed. Only this one was unsafe.)

2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath
   ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is
   still in Connecting, which deliberately fast-fails writes ("driver not connected"),
   and the NodeId->driver reverse map has not been pushed. Now retries until accepted.
   This does not weaken the assertion — every rejection branch replies WITHOUT reaching
   the driver, so Writes.Count.ShouldBe(1) still means exactly what it did.

3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_...
   One 500 ms constant served both presence budgets (AwaitAssert) and absence windows
   (ExpectNoMsg). Different quantities that happened to share a number, so the presence
   budget could not be raised without slowing every absence check. Split into
   AssertTimeout (5 s) and Settle (500 ms).

4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks
   Waited on `outbox == 0`, which is ALSO true before the first append — so the poll
   could win the race against the recorder and return having observed the INITIAL state,
   after which the CallCount>=2 check outside the block failed against a recorder that
   had not run. Both conditions are now polled together with the retry count as the
   discriminator. (The preceding test in the same file documents this exact trap.)

THE PRESENCE-BUDGET CLASS:

After those four, three consecutive 30-run rounds each still failed once — on a
DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s,
OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a
distribution, not a defect, and fixing it one test at a time was the wrong method.

RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s
AwaitAssert/AwaitCondition durations across four files route through it.

Why this is safe rather than sloppy: a presence budget is an upper bound before giving
up, not a wait — these helpers poll and return the instant the condition holds. Raising
one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass;
it only changes how quickly a real breakage reports. Absence windows are the opposite
(the elapsed time IS the assertion) and were deliberately left untouched with their own
short, individually-calibrated literals.

ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its
8 s budget was sized for message passing but actually waits on a REAL Roslyn compile
(ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold
script compiles are the slowest and most variable thing in the assembly, so that class
gets 30 s with the reason recorded.

Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13%
baseline. Full solution builds; all 41 unit-test projects green.

NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use
AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They
are excluded from the budget change on purpose — a bigger number does not fix them, and
rewriting them to settle-then-assert may legitimately turn them red.
2026-07-25 20:20:06 -04:00
Joseph Doherty 4dc2ad8084 feat(sql): implement the design §8.1 catalog identifier-validation gate (#496)
Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored
table/column names: an identifier went from the TagConfig blob straight into a
command text, bracket-quoted. The residual risk was bounded — a hostile name is
quoted into one nonexistent object, the query fails after the connection opens,
and the tag Bad-codes — but "bounded" is not "filtered", and the design promised
a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so.

SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against
the live catalog at Initialize, and REPLACE it with the catalog's own spelling.
The identifier text in an emitted poll query is therefore a string this driver
read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting
becomes the backstop it was documented to be.

Decisions worth knowing before touching this:

- Substitution, not just validation. Matching is exact-ordinal first, then a
  UNIQUE case-insensitive hit: SQL Server's default collation is CI so case
  variants have always worked, and rejecting them would break valid deployments.
  An ambiguous CI match under a case-sensitive collation is refused rather than
  guessed — picking one would publish another column's data under the operator's
  node, which is worse than rejecting the tag.

- Charset check BEFORE catalog lookup. Each identifier goes through
  QuoteIdentifier for its rejection rules first, so a name carrying a control or
  Unicode format character is rejected WITHOUT its value being echoed into a log
  line (Trojan-Source). A name that passes is safe to render, which is why
  catalog-miss messages do name it — an operator hunting a typo has to see what
  they wrote. Both halves are pinned by tests.

- A rejected tag keeps its node. It is dropped from the POLLED table but stays in
  the AUTHORED table, so it still materializes and reads BadNodeIdUnknown —
  §8.1's specified outcome. My first wiring dropped it from both, which deleted
  the node instead; an existing test (ReinitializeAsync_recoversFromFaulted)
  caught it. A status code can only be published by a node that exists, and a
  missing address-space entry is far harder to diagnose than a Bad quality.

- An unreadable catalog FAULTS Initialize; it does not reject every tag. That is
  the absence of evidence about the tags, not evidence against them — rejecting
  all of them would serve a confidently-empty address space and send the operator
  hunting typos that do not exist. Zero visible schemas is treated the same way,
  because that is exactly what a missing GRANT looks like. Faulting lands
  DriverInstanceActor in Reconnecting with its retry timer running.

- Bounded load: one schema list, one default-schema scalar, then one ListTables
  per distinct authored schema and one ListColumns per distinct authored relation
  — never a full catalog enumeration, and nothing after Initialize. Every
  authored name reaches the catalog queries as a bound @schema/@table parameter,
  so building the allow-list cannot itself be an injection vector.

- ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified
  `TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo
  would be a silent lie on any estate that maps service accounts to their own
  default schema. It is a query, not a constant, because the answer is
  per-connection.

- Accepted v1 limitation: a 3-part db.schema.table (or linked-server name)
  addresses a catalog this connection cannot enumerate, so it cannot be
  allow-listed and is rejected with a message pointing at the fix — expose the
  data through a view in the connected database.

VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and
reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a
second hand-rolled copy. (It also fixes a latent bug in the extracted code: the
OperationCanceledException arm detached the wrong task.)

Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite
catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that
suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA
path). Verified falsifiable: bypassing the gate turns exactly the three rejection
assertions red and leaves the rest green.

SqlInjectionRegressionTests is deliberately NOT rewritten to expect
BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the
quoting backstop on its own — defence in depth is only worth the name if each
layer holds independently. Rewriting those assertions would delete the backstop's
only coverage and leave the gate a single point of failure. Its scope note, which
said the gate does not exist, is updated to say why it stays where it is.
2026-07-25 16:07:53 -04:00
Joseph Doherty 1919a8e538 feat(config): reject a persisted Sql connectionString at the deploy gate (#498)
The Sql driver's "a pasted literal connection string is never persisted" guarantee was
enforced only on the READ path: SqlDriverConfigDto has no connectionString property and
UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being
WRITTEN. Config blobs are schemaless JSON columns and the fallback authoring pattern for
a driver without a typed form is a raw-JSON textarea, so an operator pasting
{"connectionString":"Server=...;Password=..."} would put a live credential in the
ConfigDb — persisted, replicated to every node's artifact cache, readable by anyone with
config access — while the runtime silently ignored it and the driver failed to connect.
Discarding a secret on read is not the same as refusing to store it.

DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy
(SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the
DeviceConfig of any device beneath it, carries a top-level connectionString key.

- Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the
  DTO sees it — a credential pasted into the device blob is the identical leak.
- The key is matched case-insensitively: System.Text.Json binds ConnectionString to a
  connectionString property by default, so an ordinal match would leave the obvious
  bypass open.
- Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and
  is not the mistake this rule exists to catch.
- The message never echoes the value — validation errors reach the AdminUI, the deploy
  log and the audit trail, and repeating the string there would leak the credential the
  rule is refusing to store. Pinned by a test.
- Malformed/blank/non-object JSON never throws: a parse failure would take down every
  other rule in the same pass.

16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail
and the 10 negatives stay green, so none of them passes vacuously.

Design §8.2's "if an inline connectionString is ever permitted (dev convenience only),
the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a
credential that has already been written.

Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third
persistence surface for a driver config blob, but it is not part of DraftSnapshot, so
this validator cannot see it.
2026-07-25 15:37:48 -04:00
Joseph Doherty b95b99ba8e fix(drivers): correct 16 wrong OPC UA status-code constants + guard them by reflection (#497)
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.
2026-07-25 15:34:31 -04:00
Joseph Doherty 4ad540376d merge: SQL poll driver (feat/sql-poll-driver)
v2-ci / build (push) Successful in 4m13s
v2-ci / unit-tests (push) Failing after 3h14m20s
Read-only Sql Equipment-kind driver: polls SQL Server tables/views on an
interval and publishes selected columns/rows as OPC UA variable nodes. Three
projects mirror the Modbus split (Contracts / runtime / Browser); ISqlDialect
seam (SqlServer only in v1); grouped one-query-per-source reads with a
three-layer client-side deadline; schema-walk address picker; typed AdminUI
driver-config + tag-config editors; env-gated central-SQL integration + a
dedicated-container blackhole gate.

Live /run gate PASSED end-to-end on docker-dev: authored a Sql driver +
device + KeyValue tag through the AdminUI, deployed, and read the live value
(dbo.TagValues.Line1.Speed = 42.5, Good) back over OPC UA.

Follow-ups filed: Gitea #496 (§8.1 catalog gate), #497 (cross-driver status
codes), #498 (connectionString persist guard).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:01:26 -04:00
Joseph Doherty 57a5bb3c60 chore(docker-dev): wire Sql__ConnectionStrings__DevSql for the Sql driver live gate
Adds the named connection-string ref the Sql poll driver resolves
(connectionStringRef: "DevSql") to both central nodes' env, pointing at a
DevSql database on the rig's own sql container. Committed-dev-secret exception,
same posture as the ConfigDb line above it. This is the env wiring the Task 21
live /run gate used to author + deploy a Sql driver and read a live value
(dbo.TagValues.Line1.Speed = 42.5) back over OPC UA.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:59:27 -04:00
Joseph Doherty b295b24419 feat(adminui): author + configure the Sql driver in /raw
The read-only Sql driver's runtime factory/probe and tag-side editors were
wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type
list, the DriverConfigModal switch, and the legacy identity dropdown all omitted
Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this.

- Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider
  (SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a
  connection string — label is explicit), optional poll/operation/command
  timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via
  JsonStringEnumConverter; a connection-string literal can never be authored
  (no such field is collected); rawTags (composer-owned) + allowWrites (inert,
  read-only v1) are never emitted.
- Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and
  DriverIdentitySection legacy dropdown.
- Add SqlDriverFormContractTests: the exact JSON the form emits constructs a
  driver through the real factory, missing connectionStringRef is rejected, and
  provider round-trips as the "SqlServer" name (never an ordinal).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:41:33 -04:00
Joseph Doherty 585f827ef3 fix(sql): close the residual credential leak in HandlePollError
The C1 credential-hygiene fix converted InitializeAsync and ReadAsync to
surface only ex.GetType().Name, but left HandlePollError building its
operator-facing Degrade() message from raw ex.Message, gated only by
'if (ex is DbException) return;'. That guard exists for the I1
double-classification concern, not for message safety, and it is incomplete
for the leak vector: a malformed connection string throws ArgumentException
from the keyword parser (the same unquoted-';'-in-password shape the sibling
SqlDriverBrowser.Sanitize special-cases), which is not a DbException and so
reaches Degrade(ex.Message) unredacted.

It is unreachable in today's control flow only because the connection string
is static and validated identically at Initialize first — an emergent
property, not an enforced invariant, and a live per-poll leak the moment a
future edit (refresh-on-reinit, a different provider) breaks that assumption.

Make the fallback type-only like the other two sites; the full exception still
reaches the log sink via the exception parameter. The I1 defer + control tests
are unaffected (they assert Degraded, not message text). Pinned by a new test
driving a credential-bearing ArgumentException through HandlePollError;
verified load-bearing (red against Degrade(ex.Message), green after).

Closes the C1 review finding on commit 37f444b9.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:39:14 -04:00
Joseph Doherty be1df2d1e5 feat(sql): SqlTagConfigEditor razor shell
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:35:25 -04:00
Joseph Doherty 9b5a96876e feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql
Add DriverTypeNames.Sql (+ All) as the shared source of truth, and wire the driver
into the Host: register the factory in DriverFactoryBootstrap.Register via
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory), add the
SqlProbe (= Driver.Sql.SqlDriverProbe) probe via TryAddEnumerable, and add the
Driver.Sql project reference to the Host. Default tier A (SQL client is managed +
cross-platform, so ShouldStub needs no change).

Repoint the interim SqlDriver.DriverTypeName / SqlDriverFactoryExtensions.DriverTypeName
literals at DriverTypeNames.Sql; both still resolve to "Sql", so the AdminUI
TagConfigEditorMap/TagConfigValidator keys and the DriverTypeName-parity test stay
green. The Core guard test discovers factories from its own bin, so it also gains a
Driver.Sql project reference — that is the "registered-factory side" that lets
DriverTypeNamesGuardTests' bidirectional-parity check pass (4/4 green).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:35:09 -04:00
Joseph Doherty 898c7365c4 test(sql): close the blackhole gate's leaked-pause race + bound the shell command
Review follow-up on the blackhole gate. Two robustness gaps, both bounded to the
disposable dedicated container (never shared infra), fixed:

- If the outer token fired while 'docker pause' was in flight, 'paused' was set
  only AFTER the await, so a cancellation there left paused=false while the
  container could still complete the pause — the finally then skipped unpause and
  leaked a frozen container. Mark paused BEFORE the await, and make the finally's
  unpause best-effort (unpausing a never-actually-paused container errors
  harmlessly, and a cleanup failure must never mask the real test outcome).
- The pause/unpause shell commands had no wall-clock bound of their own — only the
  post-pause read was capped — so a hung SSH handshake would hang CI. Give
  RunShellCommandAsync its own 30s hard cap that kills the process and throws
  TimeoutException, distinct from an operator's own cancellation.

Offline skip still clean (1 skipped, 9ms, no socket/docker).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:29:21 -04:00
Joseph Doherty 37f444b99c fix(sql): stop leaking provider exception text; de-dup health classification; guard Faulted
C1: SqlDriver's Initialize/Read failure paths no longer interpolate the ADO.NET
provider's ex.Message into LastError, the thrown message, or host status — only
ex.GetType().Name reaches the operator surface; the full exception goes to the log
sink via the structured logger's exception parameter. The driver is dialect-agnostic
over an arbitrary DbProviderFactory, so it cannot rely on any provider's message
discipline (Microsoft.Data.SqlClient's parser echoes an unrecognised keyword
lower-cased, which a value-based redactor misses).

C1a: replace the vacuous credential test (SQLite's "unable to open" message never
contains the connection string, so it passed regardless) with one that fabricates a
DbException whose own .Message carries a credential token and drives it through the
Initialize liveness-failure path via the injectable factory seam — red against the
pre-fix ex.Message interpolation.

I1: HandlePollError now ignores the DbException class ReadAsync already classified,
so a subscribed-read outage is classified (and logged) once, not twice with the
worse message.

I2: the poll's Healthy verdict routes through a shared SetHealthUnlessFaulted guard
(the one Degrade already used), so a late in-flight poll cannot un-fault a driver a
concurrent ReinitializeAsync just faulted.

I3: DiscoverAsync warns when materializing an omitted-type tag as String, the only
operator signal for the declared-vs-published type mismatch on a numeric column.

M1: BuildTagTable wraps the per-entry parse so a stray non-JsonException throw skips
the tag instead of stranding health at Initializing (it runs before Initialize's
try/catch). M2 left as a documented TODO to avoid touching SqlPollReader.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:28:17 -04:00
Joseph Doherty 443b718158 feat(sql): typed AdminUI Sql tag-config model + validator (string enums)
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's
accept/reject boundary byte-for-byte on the fields they own: model/type
serialize as NAME strings (never numbers), KeyValue requires
table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a
selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys
(top-level and nested rowSelector) survive load->save. Registered in
TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName
(DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder
SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI.

The load-bearing test rounds editor output back through
SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:27:14 -04:00
Joseph Doherty 02f2dafe2a test(sql): correct the cancelled-token test's overclaim
Its doc said it pinned "the real provider's cancellation path", but the
token is cancelled before ReadAsync so the OCE is raised at the reader's
SemaphoreSlim.WaitAsync gate, before any SqlConnection opens — it never
exercises Microsoft.Data.SqlClient's in-flight command cancellation.
Softened (option (a)) to state accurately what it proves: an already-
cancelled token propagates as OCE and is never swallowed into a Bad
snapshot or an unreachable-database verdict. The in-flight/deadline path
is covered by SqlBlackholeTimeoutTests.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:20:59 -04:00
Joseph Doherty 10a6a37ca5 test(sql): blackhole/timeout live-gate on a dedicated mssql container
Adds the frozen-peer / BadTimeout gate for the Sql driver — the single
highest-value integration test. Mirrors the S7 R2-01 blackhole gate:
docker pause a DEDICATED mssql mid-poll, assert the next read surfaces
BadTimeout within the client-side operationTimeout (≈3s) and well below
the server-side CommandTimeout backstop (30s), the driver degrades
(Degraded + host Stopped), and docker unpause recovers to Healthy/Running.

- Docker/docker-compose.yml: disposable `otopcua-sql-blackhole` mssql on
  :14333 (never the shared :14330 ConfigDb server) + one-shot seed.
- Docker/seed.sql: the two sample tables.
- SqlBlackholeTimeoutTests.cs: env-gated (SQL_BLACKHOLE_ENDPOINT); pauses
  ONLY the hard-coded container name; skips loudly if the endpoint is the
  shared port 14330; bounds its own wait so a wedged impl fails, not hangs.
  Offline: clean skip (no socket, no docker shell-out).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:20:24 -04:00
Joseph Doherty a55b7e51ca test(sql): injection regression — bind values, reject unknown identifiers
Locks the driver's injection guarantee against the SQLite fixture. An
authored VALUE ('; DROP TABLE TagValues; --) binds as a DbParameter and can
only ever be a key that matches no row (BadNoData); the seed table survives.
A hostile IDENTIFIER in table/column position is dialect-quoted into a
single nonexistent identifier — inert — and the payload never executes.

Scope, stated plainly: design §8.1 also specifies a catalog gate (validate
an authored identifier against INFORMATION_SCHEMA, reject an unknown one as
BadNodeIdUnknown). That gate does NOT exist in the driver yet and no task
here builds it, so a hostile identifier is not rejected up front — it is
quoted, the query fails after the connection opened, and the tag Bad-codes
as a query failure (BadCommunicationError), not BadNodeIdUnknown. These
tests assert what the code actually guarantees — the payload is inert and
the table intact — rather than a catalog gate that isn't there. The gate is
a tracked follow-up.

No implementation change: the reader already binds correctly (Task 7); this
suite pins it. Falsifiability-checked — forcing a real DROP before the
row-count assertion turns it red, so "table survives" is load-bearing.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:13:32 -04:00
Joseph Doherty 14fe89c505 feat(sql): AdminUI browser DI + Sql address picker body
Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy
lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor:
a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a
column attribute side-panel, manual-entry model/selector fields retained, that
composes the per-tag TagConfig blob (KeyValue / WideRow) matched to
SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and
never persisted into the composed blob or driver config.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:11:56 -04:00
Joseph Doherty 4c716242ac feat(sql): SqlDriverProbe SELECT-1 liveness check
The AdminUI "Test Connect" probe for the Sql driver: open a connection, run
the dialect's LivenessSql (SELECT 1) under a linked-CTS deadline, return
green with latency or red with a reason. Implements IDriverProbe; never
throws (malformed JSON, missing connectionStringRef, unprovisioned
connection string, a provider open failure, timeout, cancellation all
become red results).

Parses the SAME factory DTO with the SAME JsonStringEnumConverter as
SqlDriverFactoryExtensions (R2-11 factory parity, mirroring
ModbusDriverProbe) and resolves connectionStringRef the same way, so a
config that Test-Connects is the config that Deploys.

Credential hygiene: the resolved connection string never reaches the result
message. A provider exception can embed the data source in its OWN message
(the real SqlException shape), so the catch-all names the exception TYPE
only, never ex.Message. The regression test's fake connection embeds the
connection string in its thrown message specifically so surfacing ex.Message
would leak it — verified load-bearing by breaking the guard and watching it
go red.

ForTest injects factory+dialect for the offline SQLite fixture path.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:11:32 -04:00
Joseph Doherty 3cc8069150 feat(sql): factory + connectionStringRef env resolution + string-enum guard
SqlDriverFactoryExtensions turns a deployed DriverConfig blob into a live
SqlDriver, and SqlConnectionStringResolver resolves the authored
connectionStringRef NAME from Sql__ConnectionStrings__<ref> so credentials
never ride in a config blob (design §8.2).

Three behaviours worth naming:

- String-enum guard. JsonOptions carries JsonStringEnumConverter +
  camelCase, so `provider` parses whether authored as a name or an ordinal
  and always round-trips as the NAME — the systemic AdminUI defect where a
  page serialised an enum numerically against a string-typed DTO.
- Config validation lands here because nothing below does it.
  operationTimeout must be STRICTLY greater than commandTimeout (design
  §8.3); the reader and the driver stay deliberately usable with the pair
  inverted so the frozen-database tests can prove the client-side bound
  fires. Non-positive timeouts / poll interval and maxConcurrentGroups < 1
  are rejected too.
- Credential hygiene. The resolved connection string reaches the provider
  and nothing else: messages name the ref, the environment variable, or
  SqlDriver.Endpoint's credential-free server/database rendering. Both a
  log-leak and an exception-leak test cover it.

DTO changes:
- Adds RawTags (List<RawTagEntry>), following the Modbus precedent — the
  deploy artifact delivers a driver's tags this way and the DTO had no way
  to receive them, so an authored Sql tag could never reach the driver.
- Deletes SqlProbeDto + the `probe` key. It had no consumer: SqlDriver was
  specified without a background probe loop, and deliberately so — its
  IHostConnectivityProbe state is a by-product of the Initialize liveness
  check and of every poll, i.e. a statement about traffic that actually
  happened rather than a synthetic ping. On-demand connectivity is Task
  10's SqlDriverProbe. Shipping a config key that silently does nothing is
  worse than not shipping it; UnmappedMemberHandling.Skip means a blob that
  still carries `probe` keeps parsing.

allowWrites stays inert (SqlDriver implements no write capability) but an
authored `true` now WARNS rather than passing silently — the flag cannot do
harm, an operator who believes writes are enabled can.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:06:58 -04:00
Joseph Doherty 48cbc26c34 test(sql): env-gated central-SQL integration fixture + read round-trip
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:06:55 -04:00
Joseph Doherty 9e7fa5d11f feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal
Opens one transient DbConnection from a form-supplied driver-config blob and
hands ownership to SqlBrowseSession, which closes it on disposal. A database is
named either by connectionStringRef — resolved in the AdminUI process from
Sql__ConnectionStrings__<ref>, with an unresolvable ref naming the exact missing
variable — or by a pasted, session-only literal. The literal wins and the ref is
then not read; it cannot arrive from a persisted blob (SqlDriverConfigDto has no
such property), so its presence proves an operator typed it now.

Credential hygiene is the point of the type: no cached config field, nothing
persisted, and no connection text in any log line or exception. Live-probed:
Microsoft.Data.SqlClient and Microsoft.Data.Sqlite keep connection-string values
out of SqlException/SqliteException, but their connection-string PARSER echoes an
unrecognised keyword verbatim — and an unquoted ';' inside a password splits, so
the tail of the password is reported as a keyword (Password=Sup3r;SecretTail =>
"Keyword not supported: 'secrettail;connect timeout'."). The parser's message is
therefore never surfaced; every other failure additionally passes through a
substring redactor that drops the inner exception when it fires.

DriverType comes from SqlDriver.DriverTypeName, the driver's interim local
constant — DriverTypeNames.Sql is added by the driver-factory task.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:02:17 -04:00
Joseph Doherty e3e3f5fb04 fix(sql): report an ambiguous wide-row selector; pin the zombie-slot bound
Three review findings against SqlPollReader (967e5140).

1. WideRow's where-pair form emits no ORDER BY and no row limit, so a
   selector that is not actually unique made the reader publish an
   arbitrary, storage-order-dependent row — silently, unlike IndexByKey's
   duplicate-key warning. Row selection moves out of MapMember into a new
   SelectWideRow, which is called once per group and emits the same
   rate-limited contract warning (naming table, selector and row count).

   The row-limit half of the suggested fix was deliberately NOT taken: a
   TOP 1 with no ORDER BY is not deterministic either, and it would
   destroy the Rows.Count > 1 evidence the warning depends on, turning a
   loud misconfiguration into a permanently silent one. Rationale is in
   SelectWideRow's docs. No SQL changed, so no planner golden string moved.

2. The "a frozen database can never accumulate more than
   maxConcurrentGroups connections" claim was asserted in the docs and
   untested. ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQuery-
   TrulyFinishes wedges a group behind an EXCLUSIVE transaction, times it
   out, and proves the slot is still held (a second poll on a cap of 1
   times out on the gate without ever reaching the factory) and is handed
   back only once the abandoned query truly finishes. Moving the release
   from the work to the waiter fails this test and no other.

3. Task.Run thread-pool starvation: documented, not changed.
   Microsoft.Data.SqlClient's async path parks no pool thread on a frozen
   server, so the driver cannot starve itself; LongRunning would cost a
   dedicated OS thread per group per poll forever to insure against a
   provider this driver does not ship. RunGroupAsync now records the
   decision and what a BadTimeout does and does not mean, for the
   blackhole gate.

Also: the timeout warning now names the group's table, so a BadTimeout
storm identifies which source is frozen.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:57:14 -04:00
Joseph Doherty 861a1d1df0 feat(sql): SqlBrowseSession schema-walk over dialect catalog
Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with
@schema/@table bound as parameters at every level. Column leaves carry the
dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only
v1).

The NodeId encoding deliberately departs from the design sketch's literal
`schema.table|column`: SQL Server permits `.` and `|` inside a quoted
identifier, so that form mis-parses (main.a.b|c reads equally as schema
`main`+table `a.b` and schema `main.a`+table `b`) and silently binds an
operator's tag to the wrong column. SqlBrowseNodeId encodes
`<kind>:<part>[|<part>...]` with `\`/`|` escaped and the kind prefix carrying
the arity; it is public because the picker body decodes it back.

The session owns the connection it is handed and closes it on dispose -- the
registry-held session is the only lifetime hook, so a non-owning session would
leak one pooled connection per reaped picker. Per-call work stays bounded by
the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout);
no second deadline is invented here.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:49:36 -04:00
Joseph Doherty 9b30bdeb7a feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery
SqlDriver implements IDriver / ITagDiscovery / IReadable / ISubscribable /
IHostConnectivityProbe / IAsyncDisposable over the landed SqlPollReader,
mirroring ModbusDriver. IWritable is deliberately absent: v1 is read-only
structurally, and every discovered variable is SecurityClass=ViewOnly.

The shell classifies poll outcomes because nothing below it does. The reader
honours IReadable literally — it throws only when the database is unreachable
and Bad-codes everything else — so a frozen database returns all-BadTimeout
snapshots through a perfectly successful PollGroupEngine tick. Without
ObservePollOutcome the driver would report Healthy while every value was Bad.
Connection-class codes (BadTimeout / BadCommunicationError) degrade health and
report the host Stopped; authoring-class codes (unresolvable RawPath, absent
row, type mismatch) change nothing, so a tag typo never reports the database
down. It deliberately does NOT synthesise an exception to earn engine backoff:
the engine's exception path publishes nothing, which would cost clients the
Bad quality the reader went out of its way to produce.

Initialize builds the authored RawPath table first (pure, cannot fail; a
malformed TagConfig is logged and skipped) then verifies liveness over one
open-use-dispose connection bounded by wall clock as well as by token (the
R2-01 lesson) — failure records Faulted and rethrows so DriverInstanceActor
retries. The connection string is never logged: a credential-free
server/database Endpoint is the only rendering that reaches a log, LastError,
or the host status.

DriverTypeNames.Sql is NOT added here — DriverTypeNamesGuardTests asserts
bidirectional parity with registered factories, so the constant must land with
the factory (Task 11). SqlDriver.DriverTypeName carries the string until then.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:48:14 -04:00
Joseph Doherty 0efee7413a fix(sql): SqlQueryPlan snapshots its collections instead of aliasing the planner's lists
Code-review Minor: the record typed ParameterNames/Parameters/Members/
SelectedColumns as IReadOnlyList<T> but was handed the planner's live List<T>
instances. SqlQueryPlan documents itself as safe to hold for plan caching, so
an aliased list is a real hazard once the reader starts reusing plans across
polls: IReadOnlyList<T> is downcastable, and one mutation would corrupt every
subsequent poll served by that plan.

Copying at the record boundary fixes it for every call site at once rather
than relying on each planner branch remembering to call AsReadOnly.

Pinned by a test that mutates the caller's lists after construction; verified
load-bearing (reverting the record turns it red).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:31:35 -04:00
Joseph Doherty 967e5140f1 feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back
Executes a poll pass: resolve refs -> SqlGroupPlanner.Plan -> one command per
group -> slice the result set back to per-tag DataValueSnapshots, positionally
aligned with the caller's reference list (design §3.2).

The frozen-peer contract (design §8.3, the R2-01 S7 lesson) is the core of it.
CommandTimeout is only the server-side backstop; the client-side bound is a
linked CancelAfter PLUS Task.WaitAsync, because a linked token bounds only a
provider that honours it and ADO.NET providers vary. The whole per-group
operation — waiting for a concurrency slot, OpenAsync, and the query — runs off
one operationTimeout budget, so ReadAsync returns on time regardless of what the
database is doing. A breach Bad-codes that group (BadTimeout); caller
cancellation propagates instead, since nobody consumes a torn-down poll.

Also: absent row (BadNoData) stays distinct from a NULL cell (Uncertain, or Bad
under nullIsBad); duplicate plan members are all fed (two tags on one keyValue
bind one parameter but stay two members); the concurrency slot is released by the
work, not the waiter, so a frozen database can never hold more than
maxConcurrentGroups connections; the whole call throws only when the connection
itself cannot open, which is what earns PollGroupEngine's backoff.

SqlStatusCodes is driver-local, matching every sibling driver's own table — there
is no shared helper in Core.Abstractions and drivers do not reference the OPC UA
SDK. Values were read off Opc.Ua.StatusCodes rather than copied from a sibling,
because two of those tables carry transcription errors.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:26:29 -04:00
Joseph Doherty 50f88b938f fix(sql): reject Unicode format chars in identifiers; stop overclaiming catalog validation
Three findings from the code review of the dialect seam:

- QuoteIdentifier used char.IsControl, which only covers Unicode category Cc.
  Bidi overrides, zero-width spaces, soft hyphen and BOM are category Cf and
  passed through untouched. They cannot escape the brackets, so this is not an
  injection bypass — but the method rejects control characters precisely to
  protect the integrity of logged/audited statements, and Cf characters spoof
  rendered text while comparing byte-different from the real catalog name
  (Trojan Source, CVE-2021-42574). Same rule, same rationale, wider category.

- The XML docs on both files asserted that identifiers 'are sourced only from
  catalog-validated (INFORMATION_SCHEMA) names' and that rejection is 'a
  backstop, not the primary defence'. Neither is true yet: design §8.1 does
  specify that gate, but nothing implements it and no task in the plan
  schedules one, so an authored TagConfig table/column reaches QuoteIdentifier
  unfiltered. The docs now say so, because under-scrutinising this method on
  the strength of a filter that does not exist is exactly the failure mode.

- The control-character test only pinned NUL, so a regression to a
  Contains('\0') check would still have passed. Pinned the whole Cc category
  plus the new Cf rule; verified load-bearing by deleting the guard and
  observing exactly the 5 new cases go red.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:07:54 -04:00
Joseph Doherty a05cff794c test(sql): SqliteDialect + poll fixture
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:04:06 -04:00
Joseph Doherty 6de782e0fd test(sql): cover the WideRow + rejection branches of the equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:59:01 -04:00
Joseph Doherty 3d7e1226d2 refactor(sql): promote the single-row limit onto ISqlDialect
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:57:25 -04:00
Joseph Doherty 984cc875e8 feat(sql): SqlGroupPlanner folds tags into one parameterized query per group
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:52:27 -04:00
Joseph Doherty 6bb0108d30 feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL
The dialect owns the two things ADO.NET's System.Data.Common base types
cannot abstract: identifier quoting and the metadata-catalog SQL. It is
the driver's SQL-injection boundary — values always bind as DbParameters,
identifiers cannot, so the few that reach a command text are catalog-
sourced and pass through QuoteIdentifier.

QuoteIdentifier brackets one identifier part and doubles embedded ] (the
only metacharacter that can terminate a bracketed identifier early);
rejects null/empty/whitespace, >128 chars (T-SQL sysname ceiling), and any
control character (incl. NUL) as ArgumentException, without echoing the
untrusted value into the message. MapColumnType folds the design §3.7
families case-insensitively and never throws — an unrecognised family
falls back to String so a browse over an exotic column still renders;
timestamp/rowversion and time are deliberately NOT mapped to DateTime.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:43:53 -04:00
Joseph Doherty 531a110ffc chore(sql): align Driver.Sql csproj with sibling driver LangVersion convention
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:37:52 -04:00
Joseph Doherty 53ff041229 feat(sql): SqlTagDefinition + strict-enum equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:36:51 -04:00
Joseph Doherty 5b7fe9336e feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:34:50 -04:00
Joseph Doherty 3b75e1ac69 feat(sql): scaffold Driver.Sql.Browser project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:31:09 -04:00
Joseph Doherty 227cf8ee2b feat(sql): scaffold Driver.Sql runtime project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:30:11 -04:00
Joseph Doherty b13ff0be46 feat(sql): scaffold Driver.Sql.Contracts project
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:29:25 -04:00
Joseph Doherty 963eec1b32 chore: gitignore .worktrees/ for subagent-driven-development
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:26:01 -04:00
Joseph Doherty 7a9caf141c docs(drivers): add worktree + subagent execution commands to the tracking doc
v2-ci / build (push) Successful in 3m46s
v2-ci / unit-tests (push) Failing after 14m11s
Per-plan copy-paste command (subagent-driven-development in a git worktree),
plus the slash-command and executing-plans/resume alternatives, and a note that
the four independent plans can run in concurrent worktrees.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:24:36 -04:00
Joseph Doherty 88adec047b refactor(health): adopt the shared active-node check (Health 0.3.0)
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.
2026-07-24 13:21:11 -04:00
Joseph Doherty 76e55fc112 docs(drivers): Wave 0-2 tracking doc + Wave 1/2 implementation plans
Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.

Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:

- Modbus RTU        11 tasks  (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll          22 tasks  (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent   23 tasks  (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B  27 tasks  (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)

Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:17:44 -04:00
Joseph Doherty 4550486144 fix(health): active tier reports the cluster Primary, not the admin role leader
Closes the remaining half of #494, and corrects the half fixed in 8dd9da7d.

The shared ActiveNodeHealthCheck(role: "admin") answered the wrong question twice
over. It returns Healthy for any node LACKING the role, so all four driver-only
site nodes called themselves active and no consumer could find the Primary of a
site Cluster. And it selects by RoleLeader - the lowest-ADDRESSED member - which
is not where Akka places singletons.

Replaced with ClusterPrimaryHealthCheck ("cluster-primary"). One rule: this node
is active iff it is the OLDEST Up member carrying its own active role, where the
active role is admin when the node has it and driver otherwise. That serves both
consumers correctly - a fused admin node answers for admin, so Traefik pins the
AdminUI to the node hosting the singletons; a driver-only site node answers for
driver, which is exactly SelectDriverPrimary, the same election behind
IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Per-mesh scoping is
free after Phase 6: ClusterState.Members already contains only this node's own
Cluster.

SelectDriverPrimary is generalised to SelectOldestUpMemberOfRole so the
age-ordering rule has one implementation. Two copies of "oldest Up member of a
role" would be two chances to silently disagree about who is in charge, and the
tier and the redundancy snapshot must never disagree.

THE ROLE-LEADER HALF MATTERED IN PRACTICE, not just in theory. On the rebuilt rig
the two orderings diverge right now:

  akka leader (lowest address) = central-1
  oldest admin member          = central-2   <- hosts the singletons

8dd9da7d made the tier a real 503 but still selected by RoleLeader, so it would
have pinned Traefik to central-1 - the node NOT hosting the work. With this change
Traefik correctly routes to central-2.

Live-verified, exactly one 200 per mesh:

  MAIN     central-2 200   central-1 503     traefik: central-2 UP, central-1 DOWN
  SITE-A   site-a-1  200   site-a-2  503
  SITE-B   site-b-1  200   site-b-2  503

Tests: role-selection matrix and the startup-safe Degraded path in Host.Tests
(26 pass); parity between the tier's selector and the redundancy snapshot's, plus
role scoping, added to RedundancyPrimaryElectionTests, which forms a real two-node
cluster deliberately built so the oldest member is not the lowest-addressed one
(5 pass).
2026-07-24 12:43:27 -04:00
Joseph Doherty 8dd9da7d4d fix(health): bump to ZB.MOM.WW.Health 0.2.1 — Traefik leader-pinning now works
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.
2026-07-24 11:30:50 -04:00
Joseph Doherty 9ed2251f37 chore(health): bump ZB.MOM.WW.Health* to 0.2.0
v2-ci / build (push) Successful in 3m38s
v2-ci / unit-tests (push) Failing after 14m15s
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.
2026-07-24 10:00:41 -04:00
Joseph Doherty d1dac87f6f docs: record the bootstrap guard (CLAUDE.md + Phase 7 note)
v2-ci / build (push) Successful in 4m38s
v2-ci / unit-tests (push) Failing after 16m7s
The simultaneous-cold-start split-brain, carried as a Phase 7 "candidate follow-up,
not shipped", is now closed by the opt-in Cluster:BootstrapGuard (279d1d0f). Documents
the guard in CLAUDE.md's bootstrap section and marks the Phase 7 finding closed.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 08:35:16 -04:00
Joseph Doherty 279d1d0fb1 feat(cluster): simultaneous-cold-start split-brain guard (opt-in)
v2-ci / build (push) Successful in 4m46s
v2-ci / unit-tests (push) Failing after 16m9s
Closes the residual Phase-7 gap: when BOTH nodes of a 2-node pair cold-start at the
same instant, each self-first seed runs FirstSeedNodeProcess, times out waiting for
the other, and forms its own 1-node cluster — two Primaries in one pair (the Phase 6
live gate reproduced this reliably on docker). The prior mitigation was operational
(staggered start / compose depends_on), which does not exist on production hardware.

The guard (dark switch Cluster:BootstrapGuard:Enabled, default OFF) prevents the split
without giving up cold-start-alone:
- The lower-address node is the preferred founder: self-first, forms immediately.
- The higher node probes its partner's Akka port (TCP connect) up to PartnerProbeSeconds:
  reachable => peer-first (join the founder, never race it); unreachable => self-first
  (partner is dead, form alone). The order is decided BEFORE the single JoinSeedNodes,
  from an explicit reachability signal — never re-formed mid-handshake (the retired
  SelfFormAfter failure mode). When on, Akka gets no config seeds (BuildClusterOptions)
  and ClusterBootstrapCoordinator drives the join.

Review-driven hardening: case-insensitive tie-break (a hostname-casing mismatch would
reopen the split); fail-fast validation of the timing knobs; the residual "founder dies
in the probe->join window" hang is documented and made operator-visible (warning + a
restart recovers). Real-ActorSystem coordinator tests cover the load-bearing higher-node
cold-start-alone case; 147/147 Cluster tests pass.

Live-gated on docker-dev (site-a = enablement demo, serialization removed, guard on;
site-b keeps depends_on-serialization + guard off as the A/B control): simultaneous
start -> site-a-1 founds, site-a-2 probes-reachable-joins -> 250/240, NO split;
higher-node-alone -> probes dead founder, self-forms -> 250; founder rejoin -> 240.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 08:34:29 -04:00
Joseph Doherty c50ebcf7dc docs(mesh): Phase 7 DONE — failover drills PASSED, program complete
v2-ci / build (push) Successful in 3m48s
v2-ci / unit-tests (push) Failing after 14m8s
All 6 failover drills pass on the docker-dev three-mesh rig:
- D1 MAIN graceful manual-failover via the AdminUI "Trigger failover" button —
  Primary leaves, peer→250, restarts and rejoins with no split.
- D2 SITE-A auto-down failover, both directions; D3 SITE-B crash-the-oldest.
- D4 auto-down 1-vs-1 survival — every survivor stayed Up (closes the gate
  deferred since Phase 0a).
- D5 self-first cold-start-alone — a node boots with its partner down and forms
  its own mesh (gate b).
- D6 recovery — restarted nodes rejoin as Secondary (240).

Ships a one-page co-located operator runbook (OtOpcUa + ScadaBridge on shared
site VMs). Manual-failover button confirmed MAIN-only by construction; site
pairs (driver-only, no UI) fail over via auto-down. Carried Phase-6 finding —
simultaneous cold-start of both pair VMs can split — mitigated operationally in
the runbook (staggered start); a product-level join guard is a candidate
follow-up, not shipped.

The per-cluster mesh program (Phases 0a–7) is COMPLETE.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 08:01:26 -04:00
Joseph Doherty ecf5410433 docs(claude): record the Phase 6 boot-crash wiring landmine
v2-ci / build (push) Successful in 5m6s
v2-ci / unit-tests (push) Failing after 16m29s
The cluster-redundancy singleton's role scope is computed at AddAkka-configurator
time and must come from AkkaClusterOptions, never by resolving IClusterRoleInfo
there (it depends on the ActorSystem being built → stack overflow at boot). Caught
by the Phase 6 live gate; fixed in 3a4ed8dd.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 07:09:38 -04:00
Joseph Doherty 7774797770 docs(mesh): Phase 6 live gate PASSED — all 8 legs green, exit gate MET
v2-ci / build (push) Successful in 5m13s
v2-ci / unit-tests (push) Failing after 16m32s
Records the full live-gate run against master on the docker-dev three-mesh rig.
All 8 legs pass: three isolated 2-node meshes, one Primary per pair (250/240),
deploy sealed acks=6 over per-cluster ClusterClient, telemetry gRPC :4056 up +
reconnect, reconciler own-cluster-scoped, secrets pair-local, split validator
refuses Dps, and per-pair failover with auto-down 1-vs-1 survival (also closes the
deferred Phase 0a gate). Three defects were caught and fixed forward (3a4ed8dd,
792f28ec) — see the live-gate doc's Findings section. Task 9 marked completed.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 07:08:52 -04:00
Joseph Doherty 792f28ec7b fix(docker-dev): Phase 6 rig — LDAP on all nodes + serialize site-pair startup
v2-ci / build (push) Successful in 3m43s
v2-ci / unit-tests (push) Failing after 14m27s
Two docker-dev-only defects the Phase 6 live gate surfaced (both invisible to
`docker compose config`, which validates the file but never boots the app):

1. Driver-only site nodes crashed at boot with OptionsValidationException — LDAP
   transport None + AllowInsecure false. Only central carried the Security__Ldap__*
   block, but the site nodes serve OPC UA endpoints and validate LDAP options too, and
   they override `environment` wholesale (YAML `<<:` doesn't deep-merge it). Extracted
   the block to a shared &ldap-env anchor merged into every host node.

2. Site pairs split-brained at simultaneous startup (both nodes "JOINING itself",
   250/250 instead of 250/240). Both are self-first seeds, so both run Akka's
   FirstSeedNodeProcess; the partner didn't depend on its pair founder, so they raced
   and each formed a 1-node cluster. Added an &akka-founder-healthcheck (bash /dev/tcp
   probe of Akka 4053 — no nc/curl in the image) to site-a-1/site-b-1 and switched the
   partners to depends_on service_healthy, so the founder forms first and the partner
   JOINS it. (Carry the underlying simultaneous-cold-start property to Phase 7 — it is
   inherent to symmetric self-first seeding, present for central since #459.)

Live gate record: docs/plans/2026-07-24-mesh-phase6-live-gate.md (legs 1-2 PASS —
three isolated meshes, one Primary per pair at 250/240).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 06:54:27 -04:00
Joseph Doherty 3a4ed8ddb5 fix(mesh): break Phase 6 boot-crash — cluster-redundancy singleton scope must not resolve IClusterRoleInfo eagerly
Phase 6 wired the re-homed redundancy singleton in Program.cs as
`WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>())`
inside the AddAkka configurator lambda. That lambda runs WHILE the ActorSystem is
being built, but ClusterRoleInfo depends on the ActorSystem (it is a live
Cluster.State view) — so resolving it there recurses into ActorSystem construction
and every node dies at boot with StackOverflowException. It compiles cleanly (a
runtime DI cycle) and RedundancyStateSingletonRehomeTests passed a hand-built
FakeClusterRoleInfo straight into the extension, mocking around the composition-root
line that overflows. The docker-dev live gate caught it on first boot.

Derive the singleton's cluster-role scope from AkkaClusterOptions (pure config, no
ActorSystem) instead: BuildClusterRedundancySingletonOptions / the extension now take
AkkaClusterOptions and compute Role = Roles.FirstOrDefault(IsClusterRole) ?? driver —
identical to ClusterRoleInfo.ClusterRole, which derives from the same config. Program.cs
passes IOptions<AkkaClusterOptions>.Value, which has no ActorSystem dependency.

Live-verified: rebuilt image boots with zero stack-overflow lines, cluster singletons
form per mesh. 5/5 rehome tests pass; Host builds clean.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 06:54:14 -04:00
145 changed files with 19976 additions and 197 deletions
+3
View File
@@ -58,3 +58,6 @@ lib/
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
/pending.md
/current.md
# Subagent-driven-development git worktrees
.worktrees/
+3 -1
View File
@@ -226,13 +226,15 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
**A third bootstrap gap closed 2026-07-22 — downing was never the whole story, and the fix changed twice.** Even under `auto-down`, a node cold-starting while its peer was dead **never came Up**: Akka runs `FirstSeedNodeProcess` — the only bootstrap path that can form a *new* cluster when no peer answers `InitJoin` — exclusively when `seed-nodes[0]` is the nodes own address; every other node runs `JoinSeedNodeProcess` and retries `InitJoin` forever. **`Cluster:SeedNodes` is therefore ORDERED: every node that is one of its own seeds lists ITSELF first, partner second** (docker-dev `central-2` was swapped; site nodes list only `central-1` and are legitimately not seeds). `AkkaClusterOptionsValidator` (`AddValidatedOptions`/`ValidateOnStart` in `AddOtOpcUaCluster`) enforces it at boot — **conditionally** (self must be entry 0 only *if* self is in the list, or every site node would refuse to start), matching on `PublicHostname`+`Port`, never the `0.0.0.0` bind address. The earlier fix — a `Cluster:SelfFormAfter` timer calling `Cluster.Join(SelfAddress)` — is **deleted**: it sat outside Akkas join handshake, so it could not tell "no seed answered" from "a seed answered and the join is in flight", and it islanded a manually-failed-over node live before a TCP reachability guard patched that one shape. Pinned by `SelfFirstSeedBootstrapTests` (real in-process clusters, incl. a falsifiability control proving peer-first ordering never forms) + `AkkaClusterOptionsValidatorTests`. See `docs/Redundancy.md` §"Bootstrap: self-first seed ordering".
**Simultaneous cold-start splits — the bootstrap guard closes it (opt-in, `Cluster:BootstrapGuard:Enabled`, default OFF).** Self-first on BOTH nodes means when both cold-start at the same instant, each runs `FirstSeedNodeProcess`, times out, and forms its OWN 1-node cluster → two Primaries in one pair (the Phase 6 live gate reproduced this on docker; two separate clusters do NOT auto-merge). The guard prevents it without losing cold-start-alone: the lexicographically **lower** `host:port` node is the preferred founder (self-first, forms immediately); the **higher** node TCP-probes the partner's Akka port up to `PartnerProbeSeconds` (25s) — **reachable ⇒ peer-first (join the founder)**, **unreachable ⇒ self-first (partner dead, form alone)**. The order is chosen BEFORE the single `JoinSeedNodes`, from an explicit reachability signal — it never re-forms mid-handshake (the `SelfFormAfter` failure mode). When on, Akka gets NO config seeds (`BuildClusterOptions` empties `ClusterOptions.SeedNodes`) and `ClusterBootstrapCoordinator` drives the join. Residual trade (accepted, operator-visible via a warning + restart-recovers): the higher node hangs if the founder dies in the probe→join window. docker-dev: **site-a = guard demo** (guard on, serialization removed); **site-b = A/B control** (guard off, `depends_on: service_healthy` serialization). The guard is the production-faithful fix (compose `depends_on` doesn't exist on real VMs). Pinned by `ClusterBootstrapGuardTests` + `ClusterBootstrapCoordinatorTests` (real 2-node clusters, incl. the higher-node-cold-start-alone case). See `docs/Redundancy.md` §"Bootstrap guard".
Assert the **effective** cluster config off a running `ActorSystem` (as `SplitBrainResolverActivationTests` does), never the typed options or the akka.conf text — several HOCON fragments compete and the losing one still reads correctly in the file.
**RESOLVED by per-cluster mesh Phase 6 (2026-07-24) — no longer per-Akka-cluster.** The "one Primary across the whole Akka mesh" limitation described above is gone; see the Phase 6 paragraph immediately below.
**Per-cluster mesh Phase 6 (2026-07-24) — the fleet is now three independent 2-node meshes, not one.** The single fleet-wide Akka mesh was split into one mesh per application `Cluster`: the central pair (`admin,driver,cluster-MAIN`), site-a (`driver,cluster-SITE-A`), site-b (`driver,cluster-SITE-B`) — same `ActorSystem` name, separated purely by per-pair self-first seeds (each node's `Cluster:SeedNodes` lists itself first, its own pair partner second, never a node from another cluster's pair). Consequences:
- **The redundancy singleton is cluster-scoped and spawned per driver node.** `RedundancyStateActor` is now a `cluster-{ClusterId}`-scoped singleton (falls back to plain `driver` for a legacy node), so each pair elects and publishes its **own** Primary on its own mesh's DPS. **Two or more Primaries exist fleet-wide, one per pair — by design, not a bug.**
- **The redundancy singleton is cluster-scoped and spawned per driver node.** `RedundancyStateActor` is now a `cluster-{ClusterId}`-scoped singleton (falls back to plain `driver` for a legacy node), so each pair elects and publishes its **own** Primary on its own mesh's DPS. **Two or more Primaries exist fleet-wide, one per pair — by design, not a bug.** ⚠️ **Wiring landmine (fixed `3a4ed8dd`, caught by the live gate):** the singleton's cluster-role *scope* is computed at `AddAkka`-configurator time, so it must come from `AkkaClusterOptions` (config) — **never** by resolving `IClusterRoleInfo` from the SP there. `ClusterRoleInfo` depends on the `ActorSystem`, and that lambda runs *while the ActorSystem is being built*, so resolving it recurses into ActorSystem construction and **stack-overflows every node at boot** (compiles clean; the rehome tests passed a hand-built fake straight into the extension, mocking around the composition-root line). Keep `WithOtOpcUaClusterRedundancySingleton`/`BuildClusterRedundancySingletonOptions` taking `AkkaClusterOptions`.
- `ClusterNodeAddressReconciler` is **own-cluster-scoped** — an admin node can only reconcile `ClusterNode` rows in its own cluster; it has no gossip visibility into another mesh.
- `CentralCommunicationActor` creates **one `ClusterClient` per application `Cluster`** and fans commands across them (closing the `TODO(Phase 6)` Phase 2 left behind), rather than one fleet-wide client.
- **`SplitTopologyTransportValidator` fails host start** on a node carrying a `cluster-{ClusterId}` role unless it also runs the split-safe transports: `MeshTransport:Mode=ClusterClient`, `Telemetry:Mode=Grpc` (driver), `TelemetryDial:Mode=Grpc` (admin). A DPS transport on a cluster-role node would try to gossip across a partition that no longer exists.
+7 -3
View File
@@ -77,6 +77,10 @@
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
hard-coded uint constants against. Referenced by that TEST project only — the drivers
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
@@ -140,7 +144,7 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.3.0" />
<!--
LocalDb: embedded SQLite node-local cache + optional 2-node gRPC replication. The core
package floors the native SQLitePCLRaw.lib.e_sqlite3 at 2.1.12, so consumers inherit the
@@ -148,8 +152,8 @@
-->
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
+6
View File
@@ -29,6 +29,9 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
@@ -90,6 +93,9 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
+5 -3
View File
@@ -100,9 +100,11 @@ The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts.
## Failover smoke
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `central-1` and `central-2` should be listed as healthy in the `otopcua-admin` service.
2. `docker compose -f docker-dev/docker-compose.yml stop central-1``central-2` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `central-2` once its `/health/active` returns 200.
3. `docker compose -f docker-dev/docker-compose.yml start central-1``central-1` rejoins as a follower; `central-2` keeps the leader role until something disturbs it.
1. Watch the Traefik dashboard at `http://localhost:8089`. Exactly ONE of `central-1` / `central-2` is UP in the `otopcua-admin` service — the admin role-leader. The standby is DOWN by design: `/health/active` answers 503 on it, which is how Traefik pins browser traffic to the leader. (Before `ZB.MOM.WW.Health` 0.2.1 the standby answered 200, so both showed UP and this pinning silently never worked — see `lmxopcua#494`.)
2. `docker compose -f docker-dev/docker-compose.yml stop central-1``central-2` picks up the admin role-leader within ~15 s (Akka split-brain stable-after) and Traefik flips it to UP. Verified 2026-07-24: the swap completed inside 20 s with **no gap**`http://localhost:9200/` answered 200 continuously across the failover.
3. `docker compose -f docker-dev/docker-compose.yml start central-1``central-1` rejoins and **reclaims** the admin role-leader, and Traefik flips back. It does not stay with `central-2`: Akka's `RoleLeader` is the lowest-address member, so the outcome is deterministic rather than sticky.
> **Cold-boot caveat.** Starting both central nodes simultaneously from stopped can leave each self-forming its own 1-member cluster (self-first seed lists), in which case BOTH are their own role-leader and both answer `/health/active` 200. Check `entries["akka"].data.memberCount` on `/health/ready` — it should read 2. Restarting either node makes it join the survivor.
## Resource limits & dev logging
+83 -9
View File
@@ -49,14 +49,19 @@
# site-a-1, site-a-2 OTOPCUA_ROLES=driver,cluster-SITE-A — driver-only
# site-b-1, site-b-2 OTOPCUA_ROLES=driver,cluster-SITE-B — members of their
# OWN 2-node mesh (self-first seeded within the pair, NOT
# off central). They serve no UI and authenticate no
# users; central manages + deploys to them over the
# off central). They serve no UI, but they DO expose OPC UA
# data-plane endpoints (4842-4845) and authenticate those
# users against the same shared GLAuth (via the *ldap-env
# anchor); central manages + deploys to them over the
# ClusterClient/gRPC/FetchAndCache transports above, not
# gossip.
#
# Auth is real LDAP against the shared GLAuth on the Linux Docker host
# (10.100.0.35:3893, dc=zb,dc=local) — there is no LDAP container here.
# Only the admin-role central nodes carry the Security__Ldap__* block.
# EVERY host node carries the Security__Ldap__* block (shared *ldap-env anchor):
# the driver-only site nodes serve OPC UA endpoints too, and LDAP options are
# validated at boot on all of them (a node with no LDAP config crashes with
# OptionsValidationException — transport None + AllowInsecure false).
# Sign in `multi-role` / `password`.
#
# traefik PathPrefix(`/`) → central-1 / central-2 (the single UI route).
@@ -126,6 +131,43 @@ x-secrets-env: &secrets-env
Secrets__Replication__AnnounceInterval: "00:00:05"
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
# EVERY node that serves an OPC UA endpoint authenticates data-plane users against the shared GLAuth —
# that includes the driver-only site nodes (ports 4842-4845), not just the admin/central pair. LDAP
# options are validated at host start on ALL of them (Enabled=true by appsettings default, Transport=None,
# AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot.
# This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central
# still also lists the block inline for readability; the explicit keys win over the merge, same values.
x-ldap-env: &ldap-env
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
Security__Ldap__Server: "10.100.0.35"
Security__Ldap__Port: "3893"
Security__Ldap__Transport: "None"
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__GroupToRole__ReadOnly: "ReadOnly"
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
Security__Ldap__GroupToRole__WriteConfigure: "WriteConfigure"
Security__Ldap__GroupToRole__AlarmAck: "AlarmAck"
# Readiness gate for a pair FOUNDER node (site-a-1 / site-b-1): passes once its Akka port 4053 is bound.
# The partner (site-a-2 / site-b-2) waits on this via `depends_on: { condition: service_healthy }` so the
# founder forms its 2-node mesh FIRST and the partner JOINS it — instead of both self-first seeds racing
# and each forming its own single-node cluster (the split brain the Phase 6 live gate observed: 250/250,
# both Primary). `service_started` is NOT enough — it fires the instant the container launches, long
# before Akka binds. The image ships bash but no nc/curl, so the probe uses bash's /dev/tcp. start_period
# gives the founder a head start to not just bind but FORM (a 1-node cluster forms within the same second
# as the bind), so the partner's InitJoin lands on an existing cluster and is ack'd.
x-akka-founder-healthcheck: &akka-founder-healthcheck
test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/4053' || exit 1"]
interval: 3s
timeout: 2s
retries: 20
start_period: 8s
services:
sql:
@@ -226,10 +268,16 @@ services:
sql: { condition: service_healthy }
migrator: { condition: service_completed_successfully }
environment:
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-1"
@@ -345,10 +393,16 @@ services:
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-2"
@@ -438,6 +492,7 @@ services:
site-a-1:
<<: *otopcua-host
healthcheck: *akka-founder-healthcheck
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
@@ -473,6 +528,12 @@ services:
# FetchAndCache, never gossip; central and site-a share no seed list.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-2:4053"
# Simultaneous-cold-start split-brain guard (SITE-A enablement demo). With the pair no longer
# startup-serialized, this is what stops both self-first seeds forming their own 1-node cluster:
# the lower-address node (site-a-1) founds immediately; the higher (site-a-2) probes site-a-1 and
# joins it when reachable, or forms alone only if site-a-1 is truly down. Akka gets NO config
# seeds when this is on (BuildClusterOptions); ClusterBootstrapCoordinator issues JoinSeedNodes.
Cluster__BootstrapGuard__Enabled: "true"
Cluster__Roles__0: "driver"
Cluster__Roles__1: "cluster-SITE-A"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
@@ -487,7 +548,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
@@ -545,6 +606,11 @@ services:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
# SITE-A is the BOOTSTRAP-GUARD enablement demo: NO startup serialization on the pair — both
# nodes start simultaneously (the exact race that split-brained in the Phase 6 gate). The
# Cluster:BootstrapGuard (enabled in both site-a env blocks) is the ONLY thing preventing the
# split: it makes the lower-address node the founder and the higher-address node probe-then-join.
# site-b keeps the depends_on:service_healthy serialization + guard OFF, as the A/B control.
environment:
OTOPCUA_ROLES: "driver,cluster-SITE-A"
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
@@ -567,6 +633,9 @@ services:
# second) — it no longer seeds off central-1.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-2:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-1:4053"
# Bootstrap-guard enablement demo — see site-a-1. site-a-2 is the HIGHER address, so the guard
# makes it probe site-a-1 and join it (peer-first) rather than race-form its own cluster.
Cluster__BootstrapGuard__Enabled: "true"
Cluster__Roles__0: "driver"
Cluster__Roles__1: "cluster-SITE-A"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
@@ -580,7 +649,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -628,6 +697,7 @@ services:
site-b-1:
<<: *otopcua-host
healthcheck: *akka-founder-healthcheck
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
@@ -667,7 +737,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -700,6 +770,10 @@ services:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
# Serialize the SITE-B pair's startup so site-b-1 forms the mesh first and site-b-2 JOINS it — see
# the identical comment on site-a-2. Without this both self-first seeds form their own single-node
# cluster on simultaneous start (split brain).
site-b-1: { condition: service_healthy }
environment:
OTOPCUA_ROLES: "driver,cluster-SITE-B"
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
@@ -735,7 +809,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: *secrets-env
<<: [*secrets-env, *ldap-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
+55 -5
View File
@@ -401,11 +401,61 @@ Comparing against `Cluster:Hostname` would be worse than wrong: in docker-dev it
no seed URI anywhere, so the rule would silently exempt the entire rig.
Sequential recovery is island-free by construction: a `FirstSeedNodeProcess` node self-joins only when **no**
seed answered, so a peer booting after the survivor formed simply joins it as the youngest member. Both
nodes cold-starting *simultaneously* converge on one cluster while they are mutually reachable — the
`InitJoin` handshake resolves it before either self-join deadline expires. The residual risk is a boot-time
**partition** (both cold-starting, mutually unreachable): both form, which is the same dual-active class the
`auto-down` strategy already accepts, with the same recovery (restart one side).
seed answered, so a peer booting after the survivor formed simply joins it as the youngest member.
**Simultaneous cold-start CAN split — this is the residual gap the bootstrap guard below closes.** When
BOTH nodes start from nothing at the same instant, each runs `FirstSeedNodeProcess`, and if neither has
formed before the other's `seed-node-timeout` expires, EACH self-joins and forms its own single-node
cluster — two Primaries in one pair. On in-process loopback the `InitJoin` handshake usually resolves fast
enough to converge, but the Phase 6 live gate reproduced the split reliably on the docker-dev rig (real
container start timing + DNS): both nodes logged `JOINING itself … forming a new cluster` and both advertised
ServiceLevel 250. Two independent clusters do **not** auto-merge, so the split persists until an operator
restarts one side.
### Bootstrap guard (`Cluster:BootstrapGuard`, opt-in)
The guard eliminates the simultaneous-start split without giving up cold-start-alone. It is a **dark switch,
default off** — a node with `Cluster:BootstrapGuard:Enabled=false` (the default) keeps Akka's config-driven
self-first auto-join exactly as above.
When enabled, the node is given **no config seed nodes** (so Akka does not auto-join), and
`ClusterBootstrapCoordinator` picks the join order from a deterministic address tie-break plus a reachability
probe:
- The node with the lexicographically **lower** canonical `host:port` is the **preferred founder**: it joins
self-first and forms immediately if no peer answers — no probe, no delay.
- The **higher** node probes its partner's Akka port (a plain TCP connect; the partner binds its port well
before it forms) for up to `PartnerProbeSeconds` (default 25 s): **reachable ⇒ peer-first** (it joins the
founder, never races it); **unreachable after the window ⇒ self-first** (the partner is genuinely down, so
it forms alone — cold-start-alone preserved for the higher node too).
The order is decided **before** issuing a single `Cluster.JoinSeedNodes`, from an explicit reachability
signal — it never re-decides mid-handshake, the failure mode that retired the `SelfFormAfter` watchdog.
**Residual trade-off (accepted, operator-visible):** once the higher node commits peer-first it cannot
self-form (Akka's `JoinSeedNodeProcess` retries forever). If the founder dies in the small probe→join window,
the higher node hangs unjoined; the coordinator logs a clear WARNING after a bounded grace, and a **restart
recovers it** (the guard re-runs, finds the founder down, and forms alone). Config keys:
| Key | Default | Notes |
|---|---|---|
| `Cluster:BootstrapGuard:Enabled` | `false` | Dark switch. Only meaningful on a node that is one of its own two pair seeds; inert elsewhere. |
| `Cluster:BootstrapGuard:PartnerProbeSeconds` | `25` | Higher node's probe window. Must comfortably exceed the founder's process-start-to-Akka-bind time, or a slow-but-alive founder is mistaken for dead and the split re-opens. Validated `> 0` at boot. |
| `Cluster:BootstrapGuard:PartnerProbeIntervalMs` | `500` | Interval between probes. |
| `Cluster:BootstrapGuard:ProbeConnectTimeoutMs` | `1000` | Per-probe TCP connect timeout. |
On the docker-dev rig the **site-a pair is the enablement demo** (guard on, startup serialization removed so
both nodes cold-start simultaneously); **site-b keeps the `depends_on: service_healthy` startup serialization
with the guard off**, as the A/B control. Either mechanism prevents the split; the guard is the one that also
works on production hardware, where compose `depends_on` does not exist.
The alternative to the guard is **operational**: stagger the two VMs' service-manager start, or bring the
designated founder VM up first — see the Phase 7 co-located operator runbook
(`docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
The residual risk that remains even with the guard is a boot-time **partition** (both cold-starting, mutually
unreachable from the start): both form, which is the same dual-active class the `auto-down` strategy already
accepts, with the same recovery (restart one side).
Pinned by `SelfFirstSeedBootstrapTests` (`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/`), which starts real
in-process clusters through the production bootstrap at production failure-detection timings: a lone
+39
View File
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
### Post-(re)load condition-node re-assert (#487)
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
never come.
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
Four properties are load-bearing:
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
machinery — or a future refactor — to mistake for a transition.
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
alarm's display name — and writes nothing at all on the common all-normal deploy.
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
these writes.
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
## Source integration
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
{
[Theory]
[InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
[InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
{
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value);
}
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
- `ServerTimestampUtc`: `DateTime.UtcNow`.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
**Step 4: run, expect PASS.**
@@ -26,7 +26,9 @@ OpcUaClient, Historian.Gateway (`src/Drivers/`).
## 2. Document map
**Program:** this file.
**Program:** this file (architecture / shared contract / build order).
**Progress tracker (Waves 02):** [`2026-07-24-driver-expansion-tracking.md`](2026-07-24-driver-expansion-tracking.md)
— per-deliverable status + links to each design doc and implementation plan.
**Research reports** (`docs/research/drivers/`, index: [`README.md`](../research/drivers/README.md)):
- [`mtconnect-agent.md`](../research/drivers/mtconnect-agent.md) ·
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` (state word; §3.6) |
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
### 3.4 `IReadable``/current`
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
and the decisions worth knowing before touching it:
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
to the string the catalog returned, so the text quoted into a poll query is one this driver read
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
match under a case-sensitive collation is refused rather than guessed, because picking one would
publish another column's data under the operator's node.
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
hunting a typo has to see what they wrote.
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
but stays in the *authored* table, so it still materializes as a node and reads
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
drop is logged at Warning with the tag, the field and the reason.
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
them would serve a confidently-empty address space and send the operator hunting typos that do not
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
wall-clock bound as the liveness check (R2-01 / STAB-14).
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
message pointing at the fix — expose the data through a view in the connected database.
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
this keeps the factory shape unchanged.
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it
in display/logging and flags it dev-only.
- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
sees them). The key is matched case-insensitively, and the error text never echoes the value.
- **Never log the resolved connection string.** Log only provider + server host + database name.
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
Kerberos) so no password transits config at all.
+8 -1
View File
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
(a projection also carries the definition's severity, the resolved message template, and the last-observed
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
nodes at all. The original deferral text follows for context.
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
@@ -339,4 +339,4 @@ resource sizing on the site VMs should be checked once both products run the ful
| 4 cut driver ConfigDb | **DONE 2026-07-23 — live gate PASSED** on `feat/mesh-phase4` (Tasks 08 + 1b + 1011; Task 9 table-drop deferred). ConfigDb admin-only; driver-only ⇒ FetchAndCache (validator); `DbHealthProbeActor` not spawned driver-only (client-visible ServiceLevel 240/250 with central SQL down — proven live); alarm condition state in replicated LocalDb `alarm_condition_state`; central persists acks; `OpcUaPublish` guard split fixes the driver-only address-space wipe; driver-only LDAP maps from appsettings only (6th consumer found mid-phase). Gate: deploy sealed green w/ 4 DB-less site nodes acking, ServiceLevel held 240 w/ SQL down, restart booted last-known-good from the LocalDb pointer. See `2026-07-23-mesh-phase4-cut-driver-configdb.md` + `2026-07-23-mesh-phase4-live-gate.md`. |
| 5 gRPC telemetry | **DONE 2026-07-23 — live gate PASSED** on `feat/mesh-phase5`. 4-channel scope (`alerts`/`script-logs`/`driver-health`/`driver-resilience-status`), 3 deferred with rationale (`redundancy-state`, `fleet-status`, `deployment-acks`); dark switch `Telemetry:Mode`/`TelemetryDial:Mode` (Dps default); node hosts server / central dials; auth-from-day-one fail-closed bearer key, superseding design §6.3. Gate: full 12-stream mesh formed in Grpc mode (2 inbound per driver node, 6 outbound per central), AdminUI pill live (data path proven), kill-and-reconnect of a node recovered its stream in ~5s; Dps baseline dials nothing. Surfaced an upgrade gotcha — `ClusterNode.GrpcPort` must be populated on existing deployments (fresh installs seed it; nullable column doesn't backfill) — which live-validated the graceful null-`GrpcPort` skip. See `docs/Telemetry.md`, `2026-07-23-mesh-phase5-grpc-telemetry-stream.md` + `2026-07-23-mesh-phase5-live-gate.md`. |
| 6 mesh partition + co-location | **DONE 2026-07-24** — three independent 2-node meshes (central/site-a/site-b), `cluster-{ClusterId}` roles, per-pair self-first seeds, cluster-scoped `RedundancyStateActor` singleton (two+ Primaries fleet-wide, by design), one `ClusterClient` per `Cluster`, `SplitTopologyTransportValidator`, pair-local secrets replication, compiled transport defaults intentionally kept `Dps`. See `2026-07-24-mesh-phase6-partition-and-colocation.md`. |
| 7 drill + live gates | not started |
| 7 drill + live gates | **DONE 2026-07-24 — all 6 drills PASSED** on the docker-dev three-mesh rig. D1 MAIN graceful manual-failover via the AdminUI button (Primary leaves, peer→250, restarts+rejoins no split); D2 SITE-A auto-down failover both directions; D3 SITE-B crash-the-oldest; D4 **auto-down 1-vs-1 survival (closes the gate deferred since Phase 0a)** — every survivor stayed Up; D5 **self-first cold-start-alone (gate b)** — a node boots alone and forms its mesh; D6 recovery/rejoin. Manual-failover button confirmed MAIN-only by construction (site pairs are driver-only, fail over via auto-down). One-page co-located operator runbook shipped. Carried Phase-6 finding: simultaneous cold-start of both pair VMs can split — operational mitigation (staggered start) in the runbook; product-level guard is a candidate follow-up, not shipped. See `2026-07-24-mesh-phase7-failover-drills.md`. **Per-cluster mesh program COMPLETE.** |
@@ -0,0 +1,156 @@
# Driver-expansion — Waves 02 tracking
> **Living status tracker** for the driver-expansion program (Waves 0, 1, 2). One row per
> deliverable, each pointing at its **design doc**, its **implementation plan** (once written),
> and its current **status**. The authoritative *architecture* index is the program design doc;
> this file is the authoritative *progress* index.
>
> **Program design (architecture / shared contract / build order):**
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
>
> Last updated: 2026-07-24.
## Legend
| Status | Meaning |
|---|---|
| ✅ **Done** | Merged to master, tests green. |
| 🟡 **Live gate open** | Code merged; a live `/run` or hardware-gated verification still outstanding. |
| 📝 **Plan ready** | Executable implementation plan (`*-implementation.md` + `.tasks.json`) written; not yet built. |
| 📐 **Design only** | Design doc exists; **no** implementation plan yet — writing-plans is the next step. |
| ⛔ **Not started** | No design, no plan. |
**Doc types** (per the writing-plans skill): a *design* states architecture, decisions, and risks;
an *implementation plan* is the bite-sized, TDD, file-path-level task list the subagent-driven
executor runs off, with a co-located `.tasks.json` for resume. A deliverable is only buildable once
it reaches 📝.
## Summary
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|---|---|---|---|---|---|---|
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | ML | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
> Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8,
> not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is
> env-gated live. Both are out of this file's scope until they're pulled forward.
---
## Executing a plan (git worktree + subagent-per-task)
Each 📝 plan is executed with the **subagent-driven-development** skill: it sets up an isolated
**git worktree** first (via `using-git-worktrees`), then dispatches a **fresh subagent per task**,
running the classification-driven review chain (`trivial` = implement only … `high-risk` =
spec-review → code-review → integration review) between tasks. The `.tasks.json` next to each plan
tracks progress and lets a later session resume.
**Paste one of these into Claude Code to build a driver:**
| Deliverable | Command |
|---|---|
| Modbus RTU (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree` |
| SQL poll (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree` |
| MTConnect (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree` |
| MQTT/Sparkplug (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree` |
- **Slash-command form** (equivalent): `/superpowers-extended-cc:subagent-driven-development <plan-path>`.
- **Parallel-session / resume form** (batch execution with checkpoints, no fresh-subagent-per-task):
`/superpowers-extended-cc:executing-plans <plan-path>` — reads the same `.tasks.json` and continues
from the first pending task.
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
see each wave below for the gating notes). Run one plan per worktree; the four plans are
independent, so separate worktrees may run concurrently.
---
## Wave 0 — Universal Discover-backed browser 🟡
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
`ITagDiscovery.SupportsOnlineDiscovery` gate) that turns any driver's `ITagDiscovery.DiscoverAsync`
into an AdminUI browse tree. It is the **Wave-0 gate**: every browsable new driver depends on this
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
marginal cost.
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
- **Status: 🟡 code-complete + merged, live gate open.**
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
- Implementation plan: **19 / 19 tasks completed.**
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
---
## Wave 1 — low-effort / high-leverage pair 📝
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
### Modbus RTU — 📝 Plan ready
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
gateway is the only shipped mode, so there is no serial hardware gate.
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
### SQL poll — 📝 Plan ready
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks**. `ISqlDialect` seam in from day one; only the SQL Server dialect built in v1 (Postgres/ODBC deferred).
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server P1;
Postgres/ODBC land in P2/P3 behind `ISqlDialect`).
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
central SQL Server (it hosts `ConfigDb`).
- **Effort:** SM.
---
## Wave 2 — strategic telemetry / UNS pair 📝
Both CI-simulatable on the shared docker host, no hardware.
### MTConnect Agent — 📝 Plan ready
- **Design:** [`2026-07-15-mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks**. Task 0 is the TrakHound-vs-hand-rolled client decision; browse-picker live-verify is gated on Wave-0 #468.
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
- **Effort:** SM (≈11.5 wk with TrakHound, ≈2.53 wk hand-rolled).
### MQTT / Sparkplug B — 📝 Plan ready
- **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
- **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks** (P1 plain MQTT = Tasks 014, a complete shippable milestone; P2 Sparkplug B = Tasks 1526). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
(vendored Tahu proto, birth/alias/seq-gap/rebirth state machine, death→STALE).
- **Fixture:** Mosquitto/EMQX broker (TLS + real auth, not anonymous) + a **project-owned C#
Sparkplug edge-node simulator** for the rebirth/seq-gap/death test matrix + env-gated live suite
(`MQTT_FIXTURE_ENDPOINT`).
- **Effort:** ML. **Top risks:** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's
fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client).
---
## Next actions
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
table (subagent-driven-development in a git worktree).
3. **Build Wave 2** (MTConnect, then MQTT/Sparkplug) — both plans 📝 ready. MTConnect's browse leg
wants #468 closed first; MQTT's Task 0 pinning spike gates everything after it.
Update this file's Summary table and per-wave status whenever a deliverable changes state.
@@ -0,0 +1,132 @@
# Per-cluster mesh Phase 6 — live gate record
> **Exit gate for Phase 6** (mesh partition). Bring up the rewritten docker-dev rig
> (three independent 2-node Akka meshes) against **merged master** and prove every
> previously-live-gated behaviour *per pair*, plus the split itself.
>
> Merge landed first (`origin/master` @ `2839deb5`, "merge now, gate after"); this gate
> runs against the merged code and **fixes forward on master** if anything surfaces.
**Rig topology** (docker-dev, local OrbStack):
| Mesh | Nodes | Roles | OPC UA (host) | Seeds (self-first, pair-local) |
|---|---|---|---|---|
| MAIN | central-1, central-2 | `admin,driver,cluster-MAIN` | 4840 / 4841 | own pair only |
| SITE-A | site-a-1, site-a-2 | `driver,cluster-SITE-A` | 4842 / 4843 | own pair only |
| SITE-B | site-b-1, site-b-2 | `driver,cluster-SITE-B` | 4844 / 4845 | own pair only |
- Akka port 4053 (per container); telemetry gRPC `:4056`; ConfigServe `:4055` (central);
LocalDb sync `:9001` (site-a); AdminUI `http://localhost:9200` (Traefik → central-1/2).
- Transports: `MeshTransport__Mode=ClusterClient` (all), `Telemetry__Mode=Grpc` (all six),
`TelemetryDial__Mode=Grpc` (central pair). Secrets replication pair-local.
- Deploy: `POST http://localhost:9200/api/deployments`, header `X-Api-Key: docker-dev-deploy-key`.
---
## Legs
| # | Leg | Status | Evidence |
|---|---|---|---|
| 1 | Three meshes form — each node sees ONLY its own pair (2 members) | ✅ PASS | each node handshakes ONLY its pair partner |
| 2 | Two+ Primaries, one per pair; ServiceLevel 250/240 within each pair | ✅ PASS | MAIN central-2=250/central-1=240; SITE-A site-a-1=250/site-a-2=240; SITE-B site-b-1=250/site-b-2=240 |
| 3 | Deploy reaches all clusters over per-cluster ClusterClient; seals green | ✅ PASS | "Deployment 019dbd99… sealed (acks=6)"; all 6 NodeDeploymentState rows acked; one ClusterClient per site cluster |
| 4 | Telemetry per cluster over gRPC (:4056); pills live; kill/reconnect ~5s | ✅ PASS | central-1 6 outbound :4056 dials, each site node 2 inbound (both admin dial in), 0 failures/30s; site-b restart failed→recovered the streams |
| 5 | Reconciler quiet — no `EnabledRowNotInCluster` for foreign rows | ✅ PASS | central-2 reconciler: "reconcile with cluster membership (2 driver members)" — own MAIN pair only; 0 foreign-row errors |
| 6 | Secrets pair-local — no cross-mesh replication / no unreachable-peer errors | ✅ PASS (by fallback criterion) | 0 secret errors fleet-wide; DPS topic rides each pair's isolated mesh (Leg 1) ⇒ structurally pair-local |
| 7 | Split validator — a site node flipped to `MeshTransport__Mode=Dps` refuses to boot | ✅ PASS | "Cluster:Roles declares a cluster-SITE-B role … MeshTransport:Mode is 'Dps' … set MeshTransport:Mode=ClusterClient" |
| 8 | Failover within a pair — kill a Primary, Secondary takes over (auto-down 1-vs-1) | ✅ PASS | killed site-a-1 (Primary); site-a-2 auto-downed it, took the singleton, 240→250, survived alone (auto-down 1-vs-1 = deferred Phase 0a gate, now proven) |
**Phase 6 exit gate: MET.** All 8 legs pass. Three findings fixed forward on master (`3a4ed8dd` product
boot-crash, `792f28ec` rig LDAP + split-brain serialization). One test-methodology note: the Leg 7
`compose run … site-b-2` throwaway shares the `site-b-2` network alias and briefly disrupted the SITE-B
Akka association — restart the affected pair after that probe (done), or run the validator check via a
container that does not join the compose network alias.
---
## Findings
### FINDING 1 (fix-forward on master) — boot-crash: `StackOverflowException` on EVERY node
**Symptom.** First `up` of the fresh Phase-6 images: all six nodes crash at startup with
`Stack overflow.` — an infinite recursion entering through
`ZB.MOM.WW.OtOpcUa.Cluster.ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap`.
**Root cause (Phase 6, Task 2 wiring).** `Program.cs` resolved `IClusterRoleInfo` **eagerly, inside
the `AddAkka` configurator lambda**:
```csharp
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>()); // line 385
```
That lambda runs *while the `ActorSystem` is being constructed*, but `ClusterRoleInfo`'s ctor
**depends on the `ActorSystem`** (it is a live `Cluster.State` view). Resolving it there forces a
nested `ActorSystem` build → re-runs the same configurator → `sp.GetRequiredService<IClusterRoleInfo>()`
again → ∞. The cycle:
`WithOtOpcUaClusterBootstrap → GetService<IClusterRoleInfo> → ActorSystemFactory → WithOtOpcUaClusterBootstrap → …`
**Why merge + build + tests + reviews all missed it.** It compiles cleanly (a *runtime* DI cycle,
not a compile error). `RedundancyStateSingletonRehomeTests` boots a real host but passes a hand-built
`FakeClusterRoleInfo` **directly** into the extension — it never exercised the `sp.GetRequiredService`
line in the composition root that overflows. The bug lived in `Program.cs`, which every test mocked
around. Only a real boot of the shipped image surfaces it — precisely the live gate's job.
**Fix.** Derive the singleton's cluster-role scope from `AkkaClusterOptions` (pure config, no
ActorSystem) instead of `IClusterRoleInfo`:
- `BuildClusterRedundancySingletonOptions(AkkaClusterOptions)` + `WithOtOpcUaClusterRedundancySingleton(AkkaClusterOptions)`
— derive `Role = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole) ?? RoleParser.Driver`
(identical to `ClusterRoleInfo.ClusterRole`, which derives from the same config).
- `Program.cs` passes `sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value``IOptions<>` has
no ActorSystem dependency, breaking the cycle.
- Tests updated to pass `AkkaClusterOptions { Roles = … }`. 5/5 rehome tests pass; Host builds clean.
Files: `Program.cs`, `ControlPlane/ServiceCollectionExtensions.cs`, `RedundancyStateSingletonRehomeTests.cs`.
Status: **FIXED + live-verified** — rebuilt image boots with zero stack-overflow lines; all six nodes
start; cluster singletons form (`redundancy-state` identified per mesh). 5/5 rehome tests pass.
### FINDING 2 (fix-forward on master) — driver-only site nodes crash: LDAP options fail validation
**Symptom.** site-a-1/site-a-2/site-b-1/site-b-2 crash at `Host.StartAsync` with
`OptionsValidationException: LDAP transport is None (plaintext) but AllowInsecure is false`.
**Root cause (Task 7 rig).** Only central-1/central-2 carried the `Security__Ldap__*` env block; the
compose even asserted "Only the admin-role central nodes carry" it. But the site nodes serve OPC UA
endpoints (4842-4845) and LDAP options are validated at boot on every node (appsettings default
`Enabled=true`, `Transport=None`, `AllowInsecure=false` ⇒ throw). The site nodes overrode `environment`
wholesale (YAML `<<:` does not deep-merge `environment`), so they inherited no LDAP config. `docker
compose config` validates the file but never boots the app, so this passed the Task 7 acceptance check.
**Fix (rig).** Extracted the LDAP block to a shared `&ldap-env` anchor merged into **every** host node
(`<<: [*secrets-env, *ldap-env]`); corrected the two misleading header comments. Site nodes now boot.
### FINDING 3 (fix-forward on master) — site pairs split-brain at simultaneous startup (250/250)
**Symptom.** Both nodes of each site pair logged `is JOINING itself ... and forming a new cluster` and
each elected itself Primary → ServiceLevel **250/250** instead of 250/240. Each pair was two independent
1-node clusters, not one 2-node mesh.
**Root cause (Task 7 rig — a startup race, not a code defect).** Both pair nodes are self-first seeds
(`seed[0]=self`), so both run Akka's `FirstSeedNodeProcess` and can form alone. The partner
(site-a-2/site-b-2) only `depends_on` sql/central/migrator — **not its pair founder** — so the pair
started simultaneously and each formed its own cluster before discovering the other. The central pair
avoids this only because central-2 `depends_on central-1` (and even then won the InitJoin race by luck).
`depends_on: service_started` is insufficient — it fires when the container launches, before Akka binds.
This is an inherent property of symmetric self-first seeding (present for central since #459), now
extended to sites; **carry to Phase 7** as a production concern (simultaneous cold-start of both pair
VMs). NOTE: two separate 1-node clusters do NOT merge via SBR — auto-down resolves partitions of one
cluster, not two independent clusters.
**Fix (rig).** Added an `&akka-founder-healthcheck` (bash `/dev/tcp` probe of Akka port 4053; the image
has no nc/curl) to site-a-1/site-b-1, and switched site-a-2/site-b-2 to `depends_on: { site-a-1:
service_healthy }`. The founder now binds + forms its mesh before the partner starts, so the partner
JOINS it. Verified: site-a-2/site-b-2 log `Welcome from <founder>` (joined), ServiceLevel 250/240.
---
## Evidence log
- **Leg 1** — join handshakes: central-1↔central-2, site-a-1↔site-a-2, site-b-1↔site-b-2; no node
handshakes a node outside its pair. central *dials* site nodes over ClusterClient/telemetry (Akka
association + gRPC), which is the split-topology transport, NOT gossip membership.
- **Leg 2**`Client.CLI redundancy` per endpoint: 4840 central-1=240, 4841 central-2=250, 4842
site-a-1=250, 4843 site-a-2=240, 4844 site-b-1=250, 4845 site-b-2=240. One Primary per pair.
@@ -18,7 +18,7 @@
{"id": 6, "subject": "Task 6: compiled transport-mode defaults", "classification": "small", "status": "resolved-no-flip", "note": "DECIDED not to flip — blast-radius assessment: high-risk (breaks all integration tests + appsettings profiles + docker-dev site nodes), low-value (Task 5 validator already enforces split-correctness fail-closed). Documented in plan Task 6 + Configuration.md."},
{"id": 7, "subject": "Task 7: rewrite docker-dev rig into three 2-node meshes", "classification": "standard", "status": "completed", "commit": "741ab97b", "note": "3 meshes, per-pair self-first seeds, cluster roles, transports flipped (ClusterClient/Grpc), pair-local secrets note; docker compose config validates; seed SQL already correct (GrpcPort=4056)"},
{"id": 8, "subject": "Task 8: docs sweep — status tables, caveat removal, pair-local secrets note", "classification": "small", "status": "completed", "commit": "8a173bba", "note": "all 7 edits (Redundancy/Config/CLAUDE/2 plans/IManualFailoverService/ClusterRedundancy.razor); subagent died at commit step, edits reviewed+committed by controller; full-solution build clean (0 errors)"},
{"id": 9, "subject": "Task 9: live gate — three meshes, per-pair redundancy, cross-mesh transports","classification": "high-risk", "status": "in-progress", "note": "local OrbStack docker up; full-solution build verified clean; building images"}
{"id": 9, "subject": "Task 9: live gate — three meshes, per-pair redundancy, cross-mesh transports","classification": "high-risk", "status": "completed", "note": "PASSED — all 8 legs green (docs/plans/2026-07-24-mesh-phase6-live-gate.md). Caught + fixed-forward 3 defects: (1) product boot-crash StackOverflow — cluster-redundancy singleton resolved IClusterRoleInfo eagerly inside the AddAkka lambda while the ActorSystem was building (fix 3a4ed8dd: derive scope from AkkaClusterOptions); (2) rig — site nodes crashed on LDAP options (no Security__Ldap__* block); (3) rig — site pairs split-brained on simultaneous start (both self-first seeds), fixed with a founder healthcheck + service_healthy gating (2+3 in 792f28ec). Legs: 3 isolated meshes, one Primary/pair 250/240, deploy sealed acks=6 over per-cluster ClusterClient, telemetry gRPC :4056 up + reconnect, reconciler own-cluster-scoped, secrets pair-local, split validator refuses Dps, failover+auto-down-1v1 survival proven. Master 792f28ec."}
],
"lastUpdated": "2026-07-24T00:00:00Z"
"lastUpdated": "2026-07-24T11:10:00Z"
}
@@ -0,0 +1,94 @@
# Per-cluster mesh Phase 7 — failover drills + closing live gates
> **Final phase of the per-cluster mesh program.** Run the failover drill per pair type, close the two
> outstanding live gates (auto-down 1-vs-1 crash-the-oldest; self-first cold-start-alone), re-verify the
> manual-failover button, and ship a one-page co-located operator runbook.
>
> Runs against merged master on the docker-dev three-mesh rig (same rig as the Phase 6 gate).
## Scope clarified by recon
- **Failover mechanism is the same for every pair**: the oldest Up `driver` member leaves/dies, the peer
becomes oldest, takes the `cluster-{ClusterId}` redundancy singleton, and advertises ServiceLevel 250.
- **Manual-failover button (`IManualFailoverService`) is MAIN-scoped by construction** — it acts on the
local node's `Cluster.State` (graceful `Leave` of the oldest Up driver), and the AdminUI runs only on
the central (MAIN) pair. Site pairs are **driver-only, no UI**; they fail over via **auto-down** (kill)
only. This is the documented pair-local caveat from Phase 6, not a gap to fix.
- **Graceful (`docker stop`, SIGTERM → CoordinatedShutdown → cluster Leave) vs. ungraceful (`docker kill`,
SIGKILL → peer auto-downs)** are the two failover paths. Manual failover = graceful Leave; a crashed
node = auto-down.
## Drills
| # | Drill | Status | Evidence |
|---|---|---|---|
| D1 | MAIN graceful failover (manual-failover path) — Primary leaves, peer takes over | ✅ PASS | AdminUI "Trigger failover" on `/clusters/MAIN/redundancy`: central-2 "Exiting completed" → central-1 became Primary (250) → central-2 restarted, "Welcome from central-1" (rejoined, no split). Roles swapped 250↔240 |
| D2 | SITE-A auto-down failover, BOTH directions (kill Primary, then kill new Primary) | ✅ PASS | leg 8 killed site-a-1→site-a-2 Primary; D2 killed site-a-2→site-a-1 Primary (250), survived alone |
| D3 | SITE-B auto-down failover (crash-the-oldest) | ✅ PASS | killed site-b-1 (Primary/oldest) → site-b-2 "Member removed [site-b-1]", BecomingOldest→Oldest, 250 |
| D4 | Gate (a): auto-down 1-vs-1 crash-the-oldest — survivor stays Up, does not self-down | ✅ PASS | every survivor across D2/D3/D5/leg8 stayed Up (e.g. site-a-1 "Up 49 minutes", site-b-2 "Up 56 minutes") — no self-down. Closes the gate deferred since Phase 0a |
| D5 | Gate (b): self-first cold-start-alone — a node boots ALONE (partner down) and forms its mesh | ✅ PASS | stopped both site-b, started only site-b-1: "JOINING itself … forming a new cluster" → leader → Up → serves 250 as sole Primary |
| D6 | Recovery — a downed/restarted node rejoins its pair as Secondary (240) | ✅ PASS | central-2, site-a-2, site-b-2 all restarted → "Welcome from <founder>" → rejoined 240 |
**Phase 7 exit gate: MET.** All 6 drills pass on every pair type. Final fleet: three healthy 2-node
pairs, each one Primary (250) + one Secondary (240). Roles are wherever the last drill left them — each
pair has exactly one Primary, which is what matters.
## Findings / notes
- **Manual-failover button is MAIN-only, by construction** (acts on the local `Cluster.State`; AdminUI
runs only on central). The UI now correctly states "this application Cluster's own Primary … each
cluster's redundant pair runs its own independent 2-node Akka mesh" (Phase 6 text, verified live).
Site pairs (driver-only, no UI) fail over via auto-down (crash) only — there is no manual-failover
surface for them, and that is correct for the topology.
- **Graceful failover restarts the node in-place and it rejoins** (no split), because a single peer that
is already Up answers the restarting node's InitJoin. The **simultaneous cold-start** split (Phase 6
finding) only bites when BOTH pair nodes start from nothing at once — see the runbook mitigation.
- **Carried Phase-6 finding — simultaneous cold-start of both pair VMs — NOW CLOSED by a product guard
(`279d1d0f`).** On the real co-located VMs a site's two VMs can power-cycle together; two self-first
seeds can then each form a 1-node cluster (split brain, two Primaries in one pair). Two mitigations now
exist: (1) **operational** — stagger the two VMs' service-manager start / start the founder first (the
docker-dev `depends_on: service_healthy` serialization, still used by site-b); (2) **product** — the
opt-in `Cluster:BootstrapGuard` (default off), which makes the lower-address node the founder and the
higher node probe-then-join, arbitrating the race inside the join decision. Live-gated on the site-a
pair (guard on, serialization removed): simultaneous start → 250/240, no split; higher-node cold-start
-alone → self-forms → 250. See `docs/Redundancy.md` §"Bootstrap guard". The guard does NOT need the
compose `depends_on` that production hardware lacks — it is the production-faithful fix.
---
## Operator runbook — co-located OtOpcUa + ScadaBridge site failover (one page)
**Topology.** Each site = 2 Windows VMs. Each VM runs **one OtOpcUa driver node** (`driver,cluster-SITE-X`,
Akka `:4053`, OPC UA `:4840`) **and one ScadaBridge site node** — two independent 2-node Akka clusters
sharing the hardware, never a shared mesh. Central = 2 VMs, each an OtOpcUa `admin,driver,cluster-MAIN`
node (hosts the AdminUI) + a ScadaBridge central node.
**Normal state.** Each pair: one node Primary (OPC UA ServiceLevel **250**), one Secondary (**240**).
Check from any OPC UA client: `otopcua-cli redundancy -u opc.tcp://<node>:4840`.
**Planned failover (move the Primary off a VM — e.g. for patching):**
- *Central (MAIN) pair:* AdminUI → **Clusters → MAIN → Redundancy → Trigger failover → Confirm**. The
Primary gracefully leaves, restarts, rejoins as Secondary; its peer becomes Primary (250) in a few
seconds. OPC UA clients re-select the new Primary automatically.
- *Site pairs (SITE-A/SITE-B):* no UI (driver-only). Stop the OtOpcUa service on the Primary VM
(graceful SIGTERM) — the peer auto-downs it and becomes Primary. Restart the service to rejoin.
**Unplanned failover (a VM dies).** The peer detects the loss (~10-15 s: failure detector + auto-down),
removes the dead node, takes the redundancy singleton, and advertises 250. **A 1-node pair keeps
serving** (auto-down 1-vs-1 survival — verified). Because the VM is shared, **both products fail over
together**: confirm the ScadaBridge site node on the surviving VM also took over (its own runbook).
**Recovery.** Bring the dead VM back. The OtOpcUa node cold-starts, finds its peer Up, and rejoins as
Secondary (240) — "Welcome from <peer>" in its log. No manual step.
**⚠️ Both VMs down at once (site power event).** Start the VMs **staggered**, or bring the **designated
founder VM up first** and wait for its OtOpcUa node to reach ServiceLevel 250 before starting the second
VM. Starting both OtOpcUa services simultaneously can split the pair into two 1-node clusters (two
Primaries). If that happens: stop the OtOpcUa service on the non-founder VM, confirm the founder is the
lone Primary (250), then restart the non-founder — it rejoins as Secondary.
**Health checks.**
- ServiceLevel per node: `otopcua-cli redundancy -u opc.tcp://<node>:4840` (250 Primary / 240 Secondary).
- MAIN membership + Primary: AdminUI → Clusters → MAIN → Redundancy.
- A pair showing **250/250** = split brain — apply the "both VMs down" recovery above.
- A pair showing **240/240 or a lone 240** = no Primary elected — check the singleton host is Up.
+729
View File
@@ -0,0 +1,729 @@
# Modbus RTU Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Add a Modbus **RTU-over-TCP** transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing `ModbusDriver`, selected by a new `Transport` config field — reusing every codec/planner/health/materialisation surface unchanged.
**Architecture:** Purely additive behind the existing `IModbusTransport` seam. The application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU — only the wire framing differs (`[addr][PDU][CRC-16]`, no MBAP, no TxId). Work = a CRC-16 helper + a length-less FC-aware framer + one new socket transport that composes a socket lifecycle extracted from `ModbusTcpTransport` + a transport-mode selector on the config + one AdminUI field. **Zero** changes to codecs/planner/health.
**Tech Stack:** existing `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (+ `.Contracts`, `.Addressing`), xUnit + Shouldly, `pymodbus[simulator]==3.13.0` Docker fixture (RTU-framed-TCP profile; stdlib fallback server modelled on `exception_injector.py`), Blazor `ModbusDriverForm.razor` (AdminUI). No new NuGet dependency.
**Source design:** docs/plans/2026-07-15-modbus-rtu-driver-design.md
**Program design:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §7 fixtures) — this is **Wave 1**.
**Progress tracker:** docs/plans/2026-07-24-driver-expansion-tracking.md
## Scope (P1 shippable v1 only)
RTU-over-TCP via a serial→Ethernet gateway (Moxa/Digi/Lantronix), RTU CRC-16 (poly `0xA001`) + FC-aware length framing, a `Transport` selector (`Tcp` | `RtuOverTcp`) on the Modbus config, the `rtu_over_tcp` pymodbus docker profile, and the AdminUI selector. Read **and** write supported (same as TCP).
## Deferred / out of scope
- **Direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped (user, 2026-07-15). RTU buses are reached exclusively via a serial→Ethernet gateway. Design record kept in **design §2b / §9 P2**; the `ModbusRtuFraming` built here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do **not** number-squat an `Rtu` enum member for it.
- **Modbus ASCII** — a third framing (`:`-delimited hex + LRC); would be another `IModbusTransport` if ever needed (design §2c). Not planned.
- **Per-tag `TagConfig` changes** — none. `ModbusTagDefinition`/`ModbusTagDto` already carry the per-tag `UnitId` override that makes an RS-485 multi-drop bus work; the read planner already refuses to coalesce across UnitIds. Additions are **driver-level only** (design §4).
- **`ModbusDriver.BuildSlaveHostName` change** — NOT needed. It already emits `host:port/unit{n}`, the correct per-slave resilience key for a gateway (design §6). The design §1 table listed it only for the descoped serial (COM) case.
## Cross-cutting rules (program §3.1 — mandatory)
- **Enum-serialization trap**`Transport` MUST round-trip as a **name string**, never a number, on both the AdminUI serializer (`ModbusDriverForm._jsonOpts` already carries `JsonStringEnumConverter`) and the factory (DTO field typed `string?`, parsed via the existing `ParseEnum<T>` — mirror `Family`/`MelsecSubFamily`; **do not** type the DTO field as the enum). Guarded by an explicit `"transport":"RtuOverTcp"` string assertion (design §5).
- **Per-op deadline (R2-01)** — the RTU transport MUST run every transaction under a linked-CTS `CancelAfter(Options.Timeout)` — a frozen gateway must never wedge a poll. There is **no MBAP length field to trust**; FC-aware sizing is the only length signal, so getting it right (or timing out) is the top correctness risk (design §7).
- **Single-flight mandatory on RTU** — no TxId means at most one transaction on the bus at a time. Keep the `_gate` `SemaphoreSlim` in the new transport.
- **Ctor is connection-free** — the transport constructor must not connect; all I/O happens in `ConnectAsync`/`SendAsync`.
- **Live-verify discipline** — the picker/editor + deploy path get a docker-dev `/run` before trust (Task 10).
---
## Task 0: Add the `ModbusTransportMode` enum
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs`
The enum lives in `.Contracts` (backend-dep-free), namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` — same namespace as `ModbusDriverOptions`.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportModeTests
{
[Fact]
public void Tcp_is_the_default_zero_member()
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
[Fact]
public void Exactly_two_members_ship_in_v1()
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
```
**Step 2 — run & expect FAIL** (type does not exist / does not compile):
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportModeTests"`
**Step 3 — minimal impl:**
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
/// do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
Tcp,
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
RtuOverTcp,
}
```
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 1: `ModbusCrc` — CRC-16 (poly 0xA001) helper + golden vectors
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs`
CRC-16/MODBUS: reflected poly `0xA001`, init `0xFFFF`, appended **low byte first**. This is the top-of-stack of the length-less framer — golden vectors are the whole point.
**Step 1 — failing test** (table-driven against published Modbus CRC vectors):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusCrcTests
{
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
// on the wire it is appended low-byte-first.
[Theory]
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
=> ModbusCrc.Compute(frame).ShouldBe(expected);
[Fact]
public void AppendLowByteFirst_writes_lo_then_hi()
{
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var withCrc = ModbusCrc.Append(body);
withCrc.Length.ShouldBe(body.Length + 2);
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusCrcTests"`
**Step 3 — minimal impl** (`ModbusCrc.cs`): a `static ushort Compute(ReadOnlySpan<byte>)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan<byte> body)` returning `body + [crc & 0xFF, crc >> 8]`. ~30 lines. If a golden vector disagrees, fix the CRC math — never the vector.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 2: `ModbusRtuFraming` — ADU build + FC-aware length-less deframe
**Classification:** high-risk (framing)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs`
The one genuinely new correctness risk (design §2a/§7): RTU frames carry **no length field**, so response size is parsed from the function code. Read `addr(1)`+`fc(1)`, then: **exception** (`fc & 0x80`) → `excCode(1)+CRC(2)`; **reads FC01/02/03/04**`byteCount(1)` then `byteCount + CRC(2)`; **write echoes FC05/06/15/16** → fixed `4 + CRC(2)`. Validate trailing CRC, strip `addr`+`CRC`, return the bare PDU `[fc, ...data]`. CRC mismatch / truncation → `ModbusTransportDesyncException`; exception PDU (after CRC-validate) → `ModbusException`. Test directly against an in-memory `MemoryStream` of canned bytes.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuFramingTests
{
private static MemoryStream Canned(params byte[] frame) => new(frame);
[Fact]
public void BuildAdu_prefixes_unit_and_appends_crc()
{
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task ReadResponse_FC03_returns_bare_pdu()
{
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
var frame = ModbusCrc.Append(body);
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
// addr=01 fc=0x83 exc=0x02 + CRC
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.FunctionCode.ShouldBe((byte)0x03);
ex.ExceptionCode.ShouldBe((byte)0x02);
}
[Fact]
public async Task ReadResponse_bad_crc_throws_desync()
{
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
frame[^1] ^= 0xFF; // corrupt CRC high byte
await using var s = Canned(frame);
await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
{
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuFramingTests"`
**Step 3 — minimal impl** (`ModbusRtuFraming.cs`): `static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task<byte[]> ReadResponsePduAsync(Stream, byte expectedUnit, CancellationToken)` doing the FC-aware read described above with a `ReadExactlyAsync` helper (mirror `ModbusTcpTransport.ReadExactlyAsync`). Reuse `ModbusException` + `ModbusTransportDesyncException` (both already in the project). Keep framing byte-stream-agnostic (no socket knowledge) so a future serial transport can reuse it.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 3: Extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (behaviour-preserving)
**Classification:** high-risk (transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1, Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs`
Mechanical refactor: move the IPv4-preference connect (`ConnectAsync`), `EnableKeepAlive`/`ClampToWholeSeconds`, `ConnectWithBackoffAsync`, `TearDownAsync`, and the `_lastSuccessUtc`/idle-disconnect tracking out of `ModbusTcpTransport` into a reusable `ModbusSocketLifecycle` that exposes the current `NetworkStream`. `ModbusTcpTransport` keeps its MBAP `SendOnceAsync` and composes the lifecycle. **Behaviour must be byte-for-byte unchanged** — the existing `ModbusTcpReconnectTests` + `ModbusConnectionOptionsTests` are the regression net; run the whole Modbus.Tests suite green before committing.
**Step 1 — failing test** (pins the extracted surface; `ClampToWholeSeconds` moves with it):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusSocketLifecycleTests
{
[Theory]
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
[InlineData(2.0, 2)]
[InlineData(-5.0, 1)] // negative clamps to 1
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
[Fact]
public async Task Connect_to_dead_port_surfaces_socket_failure()
{
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
autoReconnect: false);
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
life.ConnectAsync(TestContext.Current.CancellationToken));
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusSocketLifecycleTests"`
**Step 3 — minimal impl:** create `ModbusSocketLifecycle` holding `_host/_port/_timeout/_autoReconnect/_keepAlive/_idleDisconnect/_reconnect`, the `TcpClient`/`NetworkStream`, and `_lastSuccessUtc`; expose `ConnectAsync`, `ConnectWithBackoffAsync`, `TearDownAsync`, `Stream` (current), `MarkSuccess()`, `ShouldReconnectForIdle()`, and the `static int ClampToWholeSeconds`. Re-point `ModbusTcpTransport` to delegate connect/reconnect/teardown/idle to it while keeping MBAP `SendOnceAsync` in place. Move (don't duplicate) `ClampToWholeSeconds` — update its one internal caller/test reference.
**Step 4 — run & expect PASS** — then run the **whole** suite to prove no regression:
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests`
**Step 5 — commit:**
`git commit -am "refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 4: `ModbusRtuOverTcpTransport` — RTU framing over the socket lifecycle
**Classification:** high-risk (framing / transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs`
`IModbusTransport` implementation: composes `ModbusSocketLifecycle` (Task 3) for connect/reconnect/keepalive/idle and `ModbusRtuFraming` (Task 2) for send/receive. Delta from `ModbusTcpTransport`: CRC framing instead of MBAP, **no TxId**. Keep the `_gate` `SemaphoreSlim` single-flight (mandatory — no TxId), the single reconnect-retry shape, and a linked-CTS `CancelAfter(Options.Timeout)` per-op deadline (design §7, R2-01). **Seam choice for unit test:** add an `internal` test ctor accepting a pre-connected `Stream` (an in-memory duplex fake) that bypasses `ConnectAsync` — this is "the fake one level below the `IModbusTransport` fakes" the design §8 calls for, and lets the send/receive orchestration + single-flight + deadline be tested with no socket.
**Step 1 — failing test** (duplex fake: canned response stream + capture of written request bytes):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuOverTcpTransportTests
{
[Fact]
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
{
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
{
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
await Should.ThrowAsync<ModbusException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
{
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<ModbusTransportDesyncException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
}
```
(The `CapturingDuplexStream` test double — a `Stream` that records writes and replays `respondBytes` on read, or blocks forever when `stall` — lives in the test file.)
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuOverTcpTransportTests"`
**Step 3 — minimal impl:** production ctor takes the same connection params as `ModbusTcpTransport` and builds a `ModbusSocketLifecycle`; `internal static ForTest(Stream, TimeSpan)` injects a pre-connected stream. `SendAsync`: `_gate.WaitAsync`; idle-reconnect check (skipped in test seam); write `ModbusRtuFraming.BuildAdu(unitId, pdu)`, flush, `ModbusRtuFraming.ReadResponsePduAsync(...)` under a linked `CancelAfter(_timeout)`; timeout-vs-caller-cancel distinction + teardown mirror `ModbusTcpTransport.SendOnceAsync`; single reconnect-retry on socket-level failure.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 5: `ModbusTransportFactory.Create` — switch on `Transport`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs`
One place that maps `ModbusDriverOptions` → the right `IModbusTransport`, consumed by both the driver default closure (Task 7) and the probe (Task 7). `Tcp``ModbusTcpTransport`, `RtuOverTcp``ModbusRtuOverTcpTransport`; both get `Host/Port/Timeout/AutoReconnect/KeepAlive/IdleDisconnect/Reconnect`.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportFactoryTests
{
[Fact]
public void Tcp_mode_builds_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void RtuOverTcp_mode_builds_rtu_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_options_build_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
```
(Depends on Task 6's `Transport` property existing on `ModbusDriverOptions` — sequence Task 6 or add the property first. If Task 5 lands before Task 6, add the property in this task instead.)
**Step 2 — run & expect FAIL.**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportFactoryTests"`
**Step 3 — minimal impl:** `static IModbusTransport Create(ModbusDriverOptions o)` = `switch (o.Transport) { RtuOverTcp => new ModbusRtuOverTcpTransport(...), _ => new ModbusTcpTransport(...) }`, passing the shared connection params.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 6: Add `Transport` to options + DTO + factory (string-enum guard)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs`
Add `public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;` to `ModbusDriverOptions`. On the DTO: add `public string? Transport { get; init; }` (typed **`string?`**, per the Modbus factory pattern — design §5) and map it with the existing `ParseEnum<ModbusTransportMode>(...)` helper, defaulting to `Tcp` when null. The guard test asserts the string wire form.
**Step 1 — failing test:**
```csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportConfigRoundTripTests
{
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
private static readonly JsonSerializerOptions _adminOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Transport_serializes_as_a_string_not_a_number()
{
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(opts, _adminOpts);
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
}
[Fact]
public void Factory_binds_RtuOverTcp_from_string_config()
{
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
}
[Fact]
public void Factory_defaults_Transport_to_Tcp_when_omitted()
{
const string json = """{ "host": "10.0.0.10" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportConfigRoundTripTests"`
**Step 3 — minimal impl:** add the `Transport` property (options) + DTO `string?` field + factory mapping `Transport = dto.Transport is null ? ModbusTransportMode.Tcp : ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport")`.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 7: Wire the driver default closure + probe to `ModbusTransportFactory`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs`
Replace the driver ctor's hardcoded `new ModbusTcpTransport(...)` default closure with `o => ModbusTransportFactory.Create(o)`. In the probe, replace the hardcoded `new ModbusTcpTransport(host, port, timeout, autoReconnect: false)` (line ~77) with a factory build carrying `Transport` (parse it into `ProbeTarget` from the DTO), `autoReconnect: false`. Now an `RtuOverTcp`-authored config probes over RTU framing — matching how it will actually run.
**Step 1 — failing test** (the default closure honours the mode; the probe carries it):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportWiringTests
{
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
{
using var driver = new ModbusDriver(opts, "wire-test");
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
return factory(opts);
}
[Fact]
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_closure_builds_tcp_transport_for_Tcp()
=> BuildDefaultTransport(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
```
(If `_transportFactory` reflection proves brittle, assert instead that a `RtuOverTcp` driver initialised against an unreachable host degrades without ever constructing an MBAP frame — but the closure-reflection test is the tightest.)
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportWiringTests"`
**Step 3 — minimal impl:** driver ctor default = `?? (o => ModbusTransportFactory.Create(o))`; probe: add `ModbusTransportMode Transport` to `ProbeTarget`, parse `dto.Transport` via `ParseEnum`, and build the probe transport through `ModbusTransportFactory.Create(new ModbusDriverOptions { Host, Port, Timeout, Transport, AutoReconnect = false })`.
**Step 4 — run & expect PASS** (+ run the full Modbus.Tests suite green).
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 8: AdminUI — `Transport` selector on `ModbusDriverForm`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs`
Add a `Transport` `<InputSelect>` to the **Protocol** panel (design's target `ModbusDriverPage.razor` is retired; `ModbusDriverForm.razor` is the live host — it serializes `ModbusDriverOptions` directly through `_jsonOpts`, which already carries `JsonStringEnumConverter`, so `Transport` emits as a name string automatically; `Transport` is **not** an endpoint key so it is not stripped by `GetConfigJson`). Add `public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;` to `FormModel`, map it in `FromOptions`/`ToOptions`. Host/Port stay on `ModbusDeviceForm` — a `RtuOverTcp` gateway uses the same `host:port` shape. Extend the existing `ModbusDriverFormModelTests` (round-trip + string-enum guard).
**Step 1 — failing test** (append to `ModbusDriverFormModelTests`):
```csharp
[Fact]
public void Round_trip_preserves_Transport_mode()
{
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverFormModelTests"`
**Step 3 — minimal impl:** add the `Transport` `FormModel` property + `FromOptions`/`ToOptions` mapping, and an `<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync">` looping `Enum.GetValues<ModbusTransportMode>()` in the Protocol panel, with a form-text hint: "RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode."
**Step 4 — run & expect PASS.** (AdminUI has no bUnit — the razor binding itself is proven live in Task 10; this model test is the reflection substitute per the AdminUI live-verify memory.)
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 9: Docker `rtu_over_tcp` fixture + RTU-over-TCP integration test
**Classification:** standard (new component / multi-file)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json`
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs`
- Modify (only if the pymodbus RTU-framer confirmation fails): `Docker/Dockerfile` + `Docker/rtu_over_tcp_server.py`
**FIRST sub-step — confirm the unknown (design §8 #1):** verify the pymodbus simulator actually exposes the RTU framer on a TCP server. The checked-in `profiles/standard.json` already sets `server_list.srv.framer: "socket"`, so the knob exists; the RTU profile sets `"framer": "rtu"` (comm stays `"tcp"`). Bring it up and probe with the new driver:
```bash
lmxopcua-fix sync modbus
lmxopcua-fix up modbus rtu_over_tcp
# then run the integration test (below) against MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021
```
If pymodbus 3.13's simulator does **not** honour `framer=rtu` on a TCP server (garbled/MBAP-framed replies), **fall back** to a stdlib `rtu_over_tcp_server.py` modelled on the existing `exception_injector.py` (same asyncio TCP-server shape, but frame with `[addr][PDU][CRC-16]` via a Python CRC-16/MODBUS instead of the MBAP header) + a `COPY rtu_over_tcp_server.py` line in the Dockerfile and a `command:` override on the service. Record which path was taken in the test-class summary.
Fixture details: bind host port **`5021`** (NOT the shared `:5020` — the `rtu_over_tcp` service must co-run with `standard` and sidestep the shared-port stale-container trap). Add `labels: { project: lmxopcua }` per the program fixture convention (existing services carry none — `lmxopcua-fix sync` normally owns the label host-side, but add it in-file for this service so it is discoverable). `ModbusRtuOverTcpFixture` mirrors `ModbusSimulatorFixture` but reads `MODBUS_RTU_SIM_ENDPOINT` (default `10.100.0.35:5021`) with the same skip-clean pattern.
**Step 1 — failing test** (`ModbusRtuOverTcpTests.cs` — read + write round-trip over RTU-over-TCP):
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
[Fact]
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, Address: 5,
DataType: ModbusDataType.UInt16, Writable: false),
]),
};
await using var driver = new ModbusDriver(opts, "modbus-rtu-int");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u); // Good
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
}
[Fact]
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition("HR200", ModbusRegion.HoldingRegisters, Address: 200,
DataType: ModbusDataType.UInt16, Writable: true),
]),
};
await using var driver = new ModbusDriver(opts, "modbus-rtu-int-w");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync([new WriteRequest("HR200", (ushort)4242)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(0u);
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
back[0].Value.ShouldBe((ushort)4242);
}
}
```
(Seed `rtu_over_tcp.json` with the same `HR[0..31]=address-as-value` + `HR[200..209]=scratch` layout as `standard.json` so these addresses resolve.)
**Step 2 — run & expect FAIL / SKIP** without the fixture, then bring it up:
```bash
lmxopcua-fix sync modbus && lmxopcua-fix up modbus rtu_over_tcp
MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests --filter "FullyQualifiedName~ModbusRtuOverTcpTests"
```
**Step 3 — minimal impl:** the `rtu_over_tcp.json` profile + compose service (+ stdlib server fallback only if the framer confirmation failed) + the fixture class. Iterate until both facts hold on the wire.
**Step 4 — run & expect PASS** against the live fixture.
**Step 5 — commit:**
`git commit -am "test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 10: Live `/run` verification on docker-dev
**Classification:** standard (live gate)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify (docs only): `docs/plans/2026-07-24-driver-expansion-tracking.md` (record Wave-1 Modbus-RTU live-gate result)
No new code — the live-verify discipline (program §3.1) proves the Razor binding + deploy path that unit tests can't. Against the local docker-dev rig (AdminUI `http://localhost:9200`, login disabled — drive it yourself; do not wait for the user to sign in):
1. Bring up the `rtu_over_tcp` fixture (`lmxopcua-fix up modbus rtu_over_tcp`, host `:5021`).
2. In `/raw`, author a Modbus device pointing Host/Port at the docker host `:5021`, and set the driver **Transport = RtuOverTcp** in the driver-config modal (the Task 8 selector). Run **Test Connect** — expect green (the probe now runs FC03 over RTU framing).
3. Author a holding-register raw tag (e.g. `HR5`), reference it into a UNS equipment, and deploy.
4. Read it back via Client.CLI against the local server, or the `/uns` value panel:
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath-of-HR5>"`
— expect Good quality + the seeded value.
5. Write to a writable register (`HR200`) via Client.CLI `write` and confirm it round-trips.
6. Record PASS/FAIL + evidence in the tracking doc.
**Step 5 — commit:**
`git commit -am "docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Ordering summary
Framer/CRC unit layer (Tasks 02) → transport (Tasks 34) → factory + config + driver/probe wiring (Tasks 57) → AdminUI selector (Task 8) → docker integration fixture (Task 9) → live `/run` (Task 10). Tasks 1 & 3 can run alongside Task 0; Task 5 alongside Task 6; Task 7 alongside Task 8. Commit after every task. DRY: `ModbusRtuFraming` and `ModbusSocketLifecycle` are each written once and composed; no codec/planner/health code is touched. YAGNI: no direct-serial, no ASCII, no per-tag config, no `BuildSlaveHostName` change.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
],
"lastUpdated": "2026-07-24"
}
@@ -0,0 +1,969 @@
# MQTT / Sparkplug B Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a standard Equipment-kind `Mqtt` driver that ingests plain MQTT (P1) then Sparkplug B (P2) into the OtOpcUa dual-namespace address space, with a bespoke observation browser, typed AdminUI editor, and TLS+auth-enforced fixtures.
**Architecture:** Three new `net10.0` projects mirror the OpcUaClient triad — `.Contracts` (transport-free options/DTOs/parser/enums), `.Driver` (subscribe-first `IDriver` holding one live MQTTnet-5 client for its whole lifetime, raising `OnDataChange` from the receive callback, with a hand-rolled reconnect loop since v5 dropped `ManagedMqttClient`), and `.Browser` (a passive `#`/birth observation-window `IBrowseSession`). Sparkplug B decodes vendored Eclipse Tahu protobuf via `Google.Protobuf`/`Grpc.Tools` over that same single client — no SparkplugNet.
**Tech Stack:** MQTTnet v5 (MIT, .NET Foundation, ships `net10.0`), vendored Eclipse Tahu `sparkplug_b.proto` + `Google.Protobuf` (already pinned 3.34.1) + `Grpc.Tools` (already pinned 2.76.0, build-time), bespoke browser.
**Source design:** docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
**Phases:** P1 (plain MQTT) = Tasks 014 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 1526 (built on the P1 skeleton). Write-through (NCMD/DCMD/plain-publish) is deferred — see "Deferred / out of scope".
---
## Cross-cutting rules (apply to every task — program design §3.1)
- **Enum-serialization trap (systemic bug):** every JSON seam that (de)serializes `MqttDriverOptions` or a tag config — factory, probe, browser, **and** the AdminUI driver-config page + probe DTO — MUST share a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` + `PropertyNameCaseInsensitive=true` + `UnmappedMemberHandling=Skip`. `MqttMode`, `MqttPayloadFormat`, `protocolVersion` are enums; serialize them as **names**. Mirror `OpcUaClientDriverFactoryExtensions`.
- **Per-op deadline (R2-01 frozen-peer lesson):** every network call (connect / subscribe / probe / rebirth-publish) has a bounded linked-CTS deadline. No unbounded waits on a dead broker.
- **Ctor is connection-free:** `MqttDriver`/`MqttDriverBrowser` constructors touch no network; all connects happen in `InitializeAsync`/`OpenAsync` (the universal-browser `CanBrowse` throwaway-instance pattern depends on this).
- **Secrets from env:** broker `password` is blank in committed JSON, supplied via env (mirror `ServerHistorian__ApiKey`). Never commit or log creds.
- **Broker security — never ship anonymous/public-broker defaults:** default `useTls=true`, real auth. `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). Fixtures run auth+TLS.
- **DriverType string is `"Mqtt"`** everywhere (driver page, probe, factory, editor map, validator, browser). One constant `DriverTypeNames.Mqtt`; grep to enforce (heed the `ModbusTcp`/`Modbus` mismatch lesson).
- **Live-verify discipline:** Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev `/run` live-verify (Tasks 14 + 26).
- **New-project csproj:** `net10.0`, `Nullable` + `ImplicitUsings` enabled, `TreatWarningsAsErrors=true` opted in per-csproj (not global).
---
## Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (this is the gate — the design's #1 dependency risk; nothing else starts until it is green)
**Files:**
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="MQTTnet" Version="5.x" />`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (temporary spike form — references `MQTTnet` only to prove the graph restores; the transport ref is removed in Task 1, `.Contracts` is transport-free)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project)
**Why first:** The design (§2.1, §10) makes this the top dependency risk — the repo uses central package management with `CentralPackageTransitivePinningEnabled` **deliberately OFF** (Roslyn 5.0.0/4.12.0 split, `Directory.Packages.props:103` comment + "Transitive pinning breaks Roslyn build" memory). We must prove **MQTTnet v5 restores and builds clean on net10 in this exact pinning configuration** before writing any driver code. Fresh-restore red-vs-local-green (the NU1903 memory) also means we validate a clean restore, not just an incremental one.
### Steps
1. Confirm on NuGet the exact latest **MQTTnet v5** version carrying a `net10.0` TFM; pin that exact version in `Directory.Packages.props` (alphabetical position near `MessagePack`).
2. Scaffold the `.Contracts` csproj (net10.0, nullable, implicit usings, TWAE) with a single `<PackageReference Include="MQTTnet" />` (no `Version` — central management supplies it). Add to `slnx`.
3. Re-verify the two external-library record claims (design §2.1 checkbox): (a) MQTTnet v5 net10.0 TFM exists; (b) SparkplugNet still transitively pins MQTTnet 4.3.x with no net10.0 TFM. Record the confirmed versions in the tasks.json note. The hand-roll decision does not hinge on (b) — it is a confirm-the-record check.
4. Prove a **clean** restore + build:
```bash
dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: no NU1605/NU1608 version-conflict, no NU1903 audit error
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: Build succeeded, 0 warnings (TWAE on)
rm -rf ~/.nuget/packages/mqttnet && dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # fresh-restore proof
```
**Expected:** all three succeed with zero version-conflict / transitive-pin / audit errors. If restore fails on a 4/5 diamond or a missing net10 TFM, STOP — the hand-roll-Tahu decision (§2.1) is vindicated but the MQTTnet-5 pin itself is the blocker; resolve the exact version before proceeding.
5. Commit:
```bash
git commit -am "feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"
```
---
## Task 1 (P1): `.Contracts` — enums + `MqttDriverOptions` DTO
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 2 depends on this)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (drop the temporary MQTTnet ref from Task 0 — `.Contracts` is transport-free; reference only `Core.Abstractions`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs` (`Plain | SparkplugB`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs` (`Json | Raw | Scalar`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs` (`V311 | V500`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs` (broker conn + mode + `sparkplug`/`plain` sub-objects, §5.1)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj` (net10, xUnit + Shouldly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Write the failing test — options round-trip enum-as-name:
```csharp
public sealed class MqttDriverOptionsTests
{
private static readonly JsonSerializerOptions J = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
{
const string json = """
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
""";
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
o.Mode.ShouldBe(MqttMode.SparkplugB);
o.UseTls.ShouldBeTrue();
o.Sparkplug!.GroupId.ShouldBe("Plant1");
o.Plain.ShouldBeNull();
}
[Fact]
public void Serialize_WritesEnumsAsNames_NotOrdinals()
{
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
s.ShouldContain("\"Plain\"");
s.ShouldNotContain("\"mode\":0");
}
}
```
2. Run — expect FAIL (types don't exist):
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
```
3. Implement the enums + `MqttDriverOptions` (with nested `MqttSparkplugOptions` / `MqttPlainOptions`) matching §5.1 defaults (`useTls=true`, `connectTimeoutSeconds=15`, `reconnectMin/MaxBackoffSeconds=1/30`). Wire the test csproj into `slnx`.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Contracts options DTO + enums (name-serialized)"` (+ session trailer).
---
## Task 2 (P1): `.Contracts``MqttTagDefinition` + `MqttEquipmentTagParser` (plain)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 6 depends on the resolver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs` (parsed per-tag descriptor — carries plain OR sparkplug variant fields; plain fields populated in P1)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs` (`TryParse(reference, out def)` + `Inspect(reference)`, mirrors `ModbusEquipmentTagParser` / `ModbusTagDefinitionFactory`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs`
### Steps (TDD)
1. Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
```csharp
[Fact]
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
{
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double","qos":1}""";
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
def!.Topic.ShouldBe("factory/oven/temp");
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
}
[Fact]
public void TryParse_TypoedPayloadFormat_RejectsStrict()
=> MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""", out _).ShouldBeFalse();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
=> MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement: a leading `{` marks a TagConfig blob; use `TagConfigJson.TryReadEnumStrict` for `payloadFormat`/`dataType` (typo → `false``BadNodeIdUnknown` upstream). `def.Name = reference`. `Inspect` returns a deploy-time warning list for present-but-invalid enums / unparseable blobs / wildcard tag-topics. Leave Sparkplug-descriptor parsing as a stub the P2 tasks fill (`groupId`/`edgeNodeId`/`deviceId`/`metricName`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain tag parser + strict-enum descriptor"` (+ trailer).
---
## Task 3 (P1): `.Driver` project + `MqttConnection` connect/TLS/auth (bounded)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 4 extends `MqttConnection`)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj` (net10, refs `.Contracts` + `Core.Abstractions` + `Core` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (MQTTnet-5 client wrapper: build options from `MqttDriverOptions` incl. TLS + CA-pin + credentials; `ConnectAsync(ct)` under `connectTimeoutSeconds` linked-CTS)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Failing test — bounded connect against a dead endpoint fails fast (no hang), and TLS-option assembly honours the knobs. Use a closed loopback port for the deadline test:
```csharp
[Fact]
public async Task ConnectAsync_DeadBroker_FailsWithinDeadline_NoHang()
{
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2 };
var conn = new MqttConnection(opts, driverId: "t", logger: null);
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<Exception>(() => conn.ConnectAsync(CancellationToken.None));
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); // deadline honoured, not wedged
}
[Fact]
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndValidatesChain()
{
var opts = new MqttDriverOptions { Host="h", Port=8883, UseTls=true,
AllowUntrustedServerCertificate=false, CaCertificatePath="/tmp/ca.pem" };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions.UseTls.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement `MqttConnection`: static `BuildClientOptions(opts, clientIdSuffix)` (host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS with `AllowUntrustedServerCertificate` → custom cert validator, `CaCertificatePath` → chain pin). `ConnectAsync(ct)` links `ct` with a `connectTimeoutSeconds` CTS. Ctor stores options only — connection-free.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline"` (+ trailer).
---
## Task 4 (P1): `MqttConnection` hand-rolled reconnect loop (backoff + resubscribe)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (reconnect loop: exponential backoff `min→max`; on `DisconnectedAsync` schedule reconnect; on reconnect fire a `Reconnected` callback so the driver re-subscribes; expose `State` = Connected/Reconnecting/Faulted + `LastMessageAgeUtc`)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
**Why high-risk:** MQTTnet v5 dropped v4's `ManagedMqttClient` (§8) — there is no library-managed reconnect. Subscriptions do not survive a clean session; a reconnect that forgets to re-subscribe silently goes dark. Backoff that ignores its cap can hot-loop a dead broker.
### Steps (TDD)
1. Failing tests — backoff schedule is bounded + monotone-to-cap, and a `Reconnected` event drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
```csharp
[Theory]
[InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(10, 30)] // caps at max=30
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
[Fact]
public async Task OnReconnect_InvokesResubscribeCallback()
{
var conn = new MqttConnection(new MqttDriverOptions(), "t", null);
var fired = 0; conn.Reconnected += () => { fired++; return Task.CompletedTask; };
await conn.RaiseReconnectedForTest(); // test seam
fired.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement: `NextBackoff(attempt, min, max)` pure; a reconnect worker that loops on disconnect with backoff, honours `cleanSession`/session-expiry, re-subscribes via the `Reconnected` callback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth. `State` transitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe"` (+ trailer).
---
## Task 5 (P1): `LastValueCache` + `IReadable`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs` (`FullReference → DataValueSnapshot`, thread-safe)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs`
### Steps (TDD)
1. Failing test — unseen reference returns a per-ref `GoodNoData`/uncertain snapshot (never throws the batch); a seeded ref returns last value:
```csharp
[Fact]
public void Read_UnseenRef_ReturnsPerRefNoData_DoesNotThrow()
{
var c = new LastValueCache();
var snap = c.Read("factory/oven/temp#$.value");
snap.StatusCode.ShouldBe(StatusCodes.GoodNoData); // or Uncertain — per IReadable contract, per-ref status
}
[Fact]
public void Update_ThenRead_ReturnsLastValue()
{
var c = new LastValueCache();
c.Update("k", DataValueSnapshot.Good(42.0, DateTime.UtcNow));
c.Read("k").Value.ShouldBe(42.0);
}
```
2. Run — expect FAIL.
3. Implement `LastValueCache` (concurrent dict); `MqttDriver.ReadAsync` (Task 7) returns `references.Select(cache.Read)` — batch never throws, per-ref `StatusCode` carries "not yet observed".
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): last-value cache backing IReadable (per-ref no-data)"` (+ trailer).
---
## Task 6 (P1): `ISubscribable` — plain topic subscribe → `OnDataChange` + retained seed
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs` (registers `FullReference → MqttTagDefinition` via the shared `EquipmentTagRefResolver<MqttTagDefinition>`; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value at `jsonPath`/raw/scalar, raise `OnDataChange`; seed from retained message on subscribe)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (expose `SubscribeAsync(filters, ct)` under a bounded deadline + a message-received event)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs`
### Steps (TDD)
1. Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire `OnDataChange` with the right `FullReference`:
```csharp
[Fact]
public void OnMessage_MatchingTopic_RaisesDataChangeAtJsonPath()
{
var mgr = new MqttSubscriptionManager();
var handle = mgr.Register(new[] { """{"topic":"f/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double"}""" });
string? gotRef = null; object? gotVal = null;
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
mgr.HandleMessage("f/oven/temp", """{"value":21.5}"""u8.ToArray(), retained: false);
gotVal.ShouldBe(21.5);
gotRef.ShouldContain("f/oven/temp");
}
[Fact]
public void OnMessage_UnauthoredTopic_RaisesNothing() // chatty broker must not auto-provision
{
var mgr = new MqttSubscriptionManager();
mgr.Register(Array.Empty<string>());
var fired = false; mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement using `EquipmentTagRefResolver<MqttTagDefinition>` (parse-once cache, as Modbus does). `HandleMessage` matches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updates `LastValueCache`, raises `OnDataChange`. Retained-flagged messages seed initial value. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver"` (+ trailer).
---
## Task 7 (P1): `MqttDriver` shell — `IDriver` + authored-only `ITagDiscovery` + `IHostConnectivityProbe`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 8/9 wire it)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable`; composes `MqttConnection` + `MqttSubscriptionManager` + `LastValueCache`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing test — `DiscoverAsync` streams **only authored tags** into a capturing `IAddressSpaceBuilder`; plain-mode `RediscoverPolicy == Once`; `SupportsOnlineDiscovery == false`:
```csharp
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.Plain }, "d", null);
driver.SetAuthoredTagsForTest(new[] { """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""" });
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement: `InitializeAsync` deserializes options (shared `JsonSerializerOptions`), builds + connects `MqttConnection`, subscribes the mode-appropriate filter. `ReinitializeAsync` applies deltas in place (never crash → Faulted). `ShutdownAsync` disconnects/disposes. `GetHealth` = Connected/Reconnecting/Faulted + last-message-age. `GetMemoryFootprint` = caches; `FlushOptionalCachesAsync` = no-op (birth/alias are correctness state — forbidden to flush; last-value backs `IReadable`). `DiscoverAsync` replays authored tags only; `RediscoverPolicy => Once` (plain). `IRediscoverable.OnRediscoveryNeeded` never fires in plain mode.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)"` (+ trailer).
---
## Task 8 (P1): `MqttDriverProbe` — CONNECT handshake
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs` (`IDriverProbe`, `DriverType => "Mqtt"`; parse config with the shared options, open a CONNECT under the timeout, green + latency on CONNACK-accepted, targeted error on refused/TLS/auth/timeout — mirrors `ModbusDriverProbe`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs`
### Steps (TDD)
1. Failing test — `DriverType` is `"Mqtt"`; a dead endpoint yields a failed (not thrown) probe result within the deadline:
```csharp
[Fact]
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
[Fact]
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
{
var r = await new MqttDriverProbe().ProbeAsync(
"""{"host":"127.0.0.1","port":1,"useTls":false,"connectTimeoutSeconds":2}""", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
```
2. Run — expect FAIL.
3. Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the **shared** `JsonSerializerOptions` (enum-as-name).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriverProbe CONNECT handshake"` (+ trailer).
---
## Task 9 (P1): Factory + `DriverTypeNames.Mqtt` + Host registration
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs` (`DriverTypeName = "Mqtt"`, shared `JsonSerializerOptions`, `Register(registry, loggerFactory)` — mirror `OpcUaClientDriverFactoryExtensions`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `public const string Mqtt = "Mqtt";` + append to the `All` list)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (add `Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)` near line 146; add `using MqttProbe = Driver.Mqtt.MqttDriverProbe;` + `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());` in `AddOtOpcUaDriverProbes` near line 124)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (ProjectReference the `.Driver` assembly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs`
### Steps (TDD)
1. Failing test — `Register` binds the `"Mqtt"` type, and the factory builds an `MqttDriver` from JSON with string enums:
```csharp
[Fact]
public void Register_ThenCreate_BuildsMqttDriver()
{
var registry = new DriverFactoryRegistry();
MqttDriverFactoryExtensions.Register(registry);
var d = registry.Create("Mqtt", "d1", """{"host":"h","port":1883,"mode":"Plain"}""");
d.ShouldBeOfType<MqttDriver>();
}
[Fact]
public void DriverTypeName_MatchesConstant()
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
```
2. Run — expect FAIL.
3. Implement + wire both host sites. Build the whole solution to confirm registration compiles:
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration"` (+ trailer).
---
## Task 10 (P1): `.Browser` — bespoke `#`-observation browser (passive)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj` (net10, refs `.Contracts` + `Commons(.Browsing)` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (`IDriverBrowser`, `DriverType => "Mqtt"`; `OpenAsync` connects with a `{clientId}-browse-{guid8}` suffix under a clamped 530 s budget; subscribes `#`/`{topicPrefix}#`; returns `MqttBrowseSession`; **publishes nothing**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (`IBrowseSession` over a thread-safe accumulating observed tree; `RootAsync`/`ExpandAsync` split topic segments on `/`, leaf = `Kind=Leaf`; `AttributesAsync` = synthetic attribute w/ inferred type + last payload snippet; `DisposeAsync` disconnects best-effort)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert `RootAsync`/`ExpandAsync` build the segment tree and **no publish** occurs on any browse call:
```csharp
[Fact]
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
{
var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
var root = await s.RootAsync(CancellationToken.None);
root.ShouldContain(n => n.BrowseName == "factory");
var lvl2 = await s.ExpandAsync("factory", CancellationToken.None);
lvl2.ShouldContain(n => n.BrowseName == "line3");
}
[Fact]
public async Task BrowseCalls_PublishNothing() // browse is read-only
{
var s = new MqttBrowseSession(MqttMode.Plain);
await s.RootAsync(CancellationToken.None);
s.PublishCountForTest.ShouldBe(0);
}
```
2. Run — expect FAIL.
3. Implement the accumulating tree + passive `OpenAsync`. In P1 only the plain `#` path is live; leave a Sparkplug hook the P2 tasks fill (`Group→EdgeNode→Device→Metric` + `RequestRebirthAsync`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): bespoke passive #-observation browser (plain)"` (+ trailer).
---
## Task 11 (P1): Register the bespoke browser (overrides universal fallback)
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 12
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();` alongside the existing two near line 7576)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ProjectReference `.Browser`)
### Steps
1. Add the registration + project reference. Registering for `DriverType="Mqtt"` overrides the universal fallback (`BrowserSessionService` resolves bespoke-first).
2. Build: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — expect success.
3. Commit: `git commit -am "feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)"` (+ trailer).
---
## Task 12 (P1): Typed AdminUI editor + validator (plain shape)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 11
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (thin typed model over a preserved `JsonObject` key bag; `FromJson`/`ToJson`/`Validate`, preserves unknown keys incl. history keys; carries a `Mode` field selecting sub-shape — P1 implements Plain `Validate`, Sparkplug stub filled in Task 24)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (`[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (`[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate()`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (copy the Modbus editor template — mode-switch at top, per-mode field group, client-side `Validate()`)
- Create: `tests/Server/.../MqttTagConfigModelTests.cs` (co-locate with the existing AdminUI test project — verify path with `find tests/Server -name "*TagConfigModel*Tests.cs"`)
### Steps (TDD)
1. Failing test — round-trip preserves unknown/history keys; plain `Validate` requires concrete topic + jsonPath-when-Json; re-derives `FullName`:
```csharp
[Fact]
public void FromJson_ToJson_PreservesUnknownKeys()
{
var m = MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","isHistorized":true}""");
m.ToJson().ShouldContain("isHistorized");
}
[Fact]
public void Validate_PlainWildcardTopic_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/+/c","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeEmpty();
[Fact]
public void Validate_JsonWithoutPath_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""")
.Validate().ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement the model (mirror `OpcUaClientTagConfigModel`); `ToJson` writes PascalCase `FullName` re-derived from descriptor (`{topic}#{jsonPath}` plain). Build the `.razor`. Register both map entries.
4. Run — expect PASS + `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`.
5. Commit: `git commit -am "feat(mqtt): typed tag editor + validator (plain mode)"` (+ trailer).
---
## Task 13 (P1): Mosquitto + JSON-publisher fixture (TLS+auth) + env-gated live suite
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (`eclipse-mosquitto` with **auth + TLS** on `:8883`, plain `:1883` for smoke only; a JSON-publisher container emitting `retain=true` JSON on a few topics — `mosquitto_pub` loop or `paho-mqtt` script; `project=lmxopcua` label applied host-side)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf` + password/cert material generator script (creds via env, never real secrets committed)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs` (category `LiveIntegration`, gated on `MQTT_FIXTURE_ENDPOINT` — skips clean when unset → macOS-offline-safe)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps
1. Write the compose with **auth + TLS** (never anonymous). Add the JSON publisher with `retain=true`.
2. Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence. `[SkippableFact]` reading `MQTT_FIXTURE_ENDPOINT`.
3. Confirm offline skip:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
```
4. Deploy the fixture to the docker host and run live (design §9):
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite"` (+ trailer).
---
## Task 14 (P1): Live `/run` verify on docker-dev — **P1 MILESTONE COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `CLAUDE.md` (add the MQTT fixture endpoint to the Docker Workflow endpoint list: `10.100.0.35:1883/8883`, `MQTT_FIXTURE_ENDPOINT`)
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT P1 code-complete)
### Steps
1. On docker-dev (`:9200`, login disabled — run it yourself, do not defer): author an `Mqtt` driver (Plain mode) + a tag via the `/uns` picker (bespoke `#`-observation browser) or the typed editor; deploy.
2. Confirm via Client.CLI the node carries live broker values:
```bash
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
```
3. Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
4. Commit: `git commit -am "docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded"` (+ trailer).
---
# ── P2: Sparkplug B ingest (builds on the P1 skeleton) ──
## Task 15 (P2): Vendor Tahu proto + `Grpc.Tools` codegen
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (P2 root)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto` (vendored Eclipse Tahu `sparkplug_b.proto`, pinned to a known Tahu commit with a provenance comment header)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (add `Google.Protobuf` + build-time `Grpc.Tools` (`PrivateAssets=all`); `<Protobuf Include="Protos/sparkplug_b.proto" GrpcServices="None" />` — message-only codegen, this repo's first in-repo protoc step)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs`
### Steps (TDD)
1. Failing test — the generated `Payload`/`Metric` types exist and round-trip a hand-built payload:
```csharp
[Fact]
public void GeneratedPayload_RoundTrips()
{
var p = new Org.Eclipse.Tahu.Protobuf.Payload { Seq = 3 };
p.Metrics.Add(new Org.Eclipse.Tahu.Protobuf.Payload.Types.Metric { Name = "Temperature", Alias = 5 });
var back = Org.Eclipse.Tahu.Protobuf.Payload.Parser.ParseFrom(p.ToByteArray());
back.Seq.ShouldBe(3ul);
back.Metrics[0].Alias.ShouldBe(5ul);
}
```
2. Run — expect FAIL (no generated types).
3. Vendor the proto (provenance comment: Tahu commit hash + URL); wire `Grpc.Tools`. If in-repo protoc proves objectionable, fall back to checking in the generated C# with the same provenance comment (design §2.1). Build:
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen"` (+ trailer).
---
## Task 16 (P2): `SparkplugCodec` decode + golden payloads
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs` (decode `Payload`/`Metric` from wire bytes → a driver-side struct; encode NCMD deferred to `RebirthRequester` Task 20 / write-through P3)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin` + `ndata.bin` (golden byte vectors, generated once from a hand-built payload and committed)
### Steps (TDD)
1. Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
```csharp
[Fact]
public void Decode_GoldenNbirth_ExtractsMetrics()
{
var payload = SparkplugCodec.Decode(File.ReadAllBytes("Golden/nbirth.bin"));
payload.Seq.ShouldBe((byte)0);
payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5 && m.DataType == SparkplugDataType.Float);
}
```
2. Run — expect FAIL.
3. Implement `SparkplugCodec.Decode`; generate the golden vectors in a one-off `[Fact(Skip="generator")]` or a small helper, commit the `.bin` files.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SparkplugCodec decode + golden payload vectors"` (+ trailer).
---
## Task 17 (P2): `SparkplugTopic` + `SparkplugDataType.ToDriverDataType`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs` (parse/format `spBv1.0/{group}/{type}/{node}[/{device}]`; `type` ∈ NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs` (enum + `ToDriverDataType()` per the §3.5 map: Int8→Int16, UInt8→UInt16, DataSet/Template unsupported, `*Array`→element+IsArray)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs`
### Steps (TDD)
1. Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
```csharp
[Fact]
public void Parse_DeviceData_ExtractsAllSegments()
{
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
t.GroupId.ShouldBe("Plant1"); t.Type.ShouldBe(SparkplugMessageType.DDATA);
t.EdgeNodeId.ShouldBe("EdgeA"); t.DeviceId.ShouldBe("Filler1");
}
[Theory]
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
[InlineData(SparkplugDataType.UInt8, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
=> s.ToDriverDataType().ShouldBe(d);
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug topic parse + datatype map"` (+ trailer).
---
## Task 18 (P2): `BirthCache` + `AliasTable` — bind-by-name, rebuild-per-birth
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 19
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs` (per `(edgeNode,device)` alias→(name,datatype); **rebuilt wholesale each birth, never merged**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs` (NBIRTH/DBIRTH metric catalog: name/alias/datatype/last value)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs`
**Why high-risk:** §3.6 invariants #1#2 — binding data by alias silently mis-routes when an alias is reused for a different metric across a rebirth. Bind by **stable metric NAME**; the alias is a per-birth cache only.
### Steps (TDD)
1. Failing tests — the two load-bearing invariants:
```csharp
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Temperature");
// rebirth: alias 5 now means a DIFFERENT metric
t.RebuildFromBirth(new[] { (name:"Pressure", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Pressure"); // wholesale replace, not merge
}
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"A", alias:1ul, dt:SparkplugDataType.Int32) });
t.RebuildFromBirth(new[] { (name:"B", alias:2ul, dt:SparkplugDataType.Int32) });
t.TryResolve(alias: 1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
}
```
2. Run — expect FAIL.
3. Implement — `RebuildFromBirth` replaces the whole map. `BirthCache` keeps the metric catalog + last value keyed by name.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth"` (+ trailer).
---
## Task 19 (P2): `SequenceTracker` — seq-gap + bdSeq death-tie
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 18
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs` (per edge-node `seq` 0255 wrap gap detection; `bdSeq` ties NDEATH↔NBIRTH)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs`
**Why high-risk:** §3.6 invariant #3 — a wrong wrap boundary (255→0 is NOT a gap) either misses real gaps (stale data) or false-positives every wrap (rebirth-storm).
### Steps (TDD)
1. Failing tests — contiguous ok, wrap ok, gap detected:
```csharp
[Fact]
public void Sequence_WrapAt255IsContiguous_NotAGap()
{
var s = new SequenceTracker();
s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap
}
[Fact]
public void Sequence_SkippedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
}
```
2. Run — expect FAIL.
3. Implement (next-expected = `(last+1) & 0xFF`); `bdSeq` compare helper.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie"` (+ trailer).
---
## Task 20 (P2): `RebirthRequester` — NCMD encode
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/RebirthRequester.cs` (encode an NCMD writing `Node Control/Rebirth = true` to `spBv1.0/{group}/NCMD/{node}`; publish under a bounded deadline)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs`
### Steps (TDD)
1. Failing test — encoded NCMD carries the `Node Control/Rebirth`=true metric and targets the right topic:
```csharp
[Fact]
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
{
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
}
```
2. Run — expect FAIL.
3. Implement (encode path via the generated proto).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): RebirthRequester NCMD encode"` (+ trailer).
---
## Task 21 (P2): Sparkplug ingest state machine — the §3.6 matrix
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (integrates Tasks 1620 into the driver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs` (routes decoded messages: N/DBIRTH → rebuild `AliasTable` + `BirthCache`; N/DDATA → resolve alias→name → map to authored `FullReference` **by (group,node,device,metricName)**`OnDataChange`; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth → `RebirthRequester` gated on `requestRebirthOnGap`; STATE/primary-host handling; late-join rebirth on connect)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (Sparkplug-mode subscribe `spBv1.0/{group}/#` (+ STATE) → `SparkplugIngestor`; reconnect → re-subscribe + request rebirth)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs`
**Why high-risk:** this is the §3.6 correctness core — the #1 risk in the design. Test the full matrix: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
### Steps (TDD)
1. Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
```csharp
[Fact]
public void Birth_Then_Data_ResolvesByAlias_RaisesOnDataChangeByName()
{
var ing = new SparkplugIngestor(...); // authored tag: Plant1/EdgeA/Filler1:Temperature
string? firedRef = null; ing.OnDataChange += (_, e) => firedRef = e.FullReference;
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:1, new[] { (alias:5ul, val:(object)21.5f) });
firedRef.ShouldContain("Filler1:Temperature");
}
[Fact]
public void DataBeforeBirth_RequestsRebirth()
{
var ncmds = new List<string>();
var ing = new SparkplugIngestor(..., publishNcmd: (t,_) => ncmds.Add(t));
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:0, new[] { (alias:9ul, val:(object)1f) });
ncmds.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
[Fact]
public void Ddeath_EmitsStaleForDeviceMetrics_NextBirthRestoresGood()
{
var ing = new SparkplugIngestor(...);
var quals = new List<StatusCode>(); ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdeath("Plant1","EdgeA","Filler1");
quals.ShouldContain(q => StatusCode.IsBad(q)); // STALE/Bad on death
}
```
2. Run — expect FAIL.
3. Implement the ingestor holding the §3.6 invariants. Route via `EquipmentTagRefResolver` keyed by `(group,node,device,metricName)`.
4. Run — expect PASS (all matrix cases).
5. Commit: `git commit -am "feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)"` (+ trailer).
---
## Task 22 (P2): `ITagDiscovery` `UntilStable` + `IRediscoverable`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`RediscoverPolicy => UntilStable` in Sparkplug mode — authored tags' datatypes fill in as births arrive; `DiscoverAsync` re-streams authored tags with resolved datatypes from `BirthCache`; fire `OnRediscoveryNeeded` on a **new DBIRTH** or a rebirth with a changed metric set, `ScopeHint` = edge-node/device folder path)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug policy is `UntilStable`; a new DBIRTH fires `OnRediscoveryNeeded`; a tag authored before its birth picks up the birth datatype:
```csharp
[Fact]
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
=> new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null)
.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
[Fact]
public async Task NewDbirth_FiresOnRediscoveryNeeded()
{
var d = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null);
var fired = false; ((IRediscoverable)d).OnRediscoveryNeeded += (_, _) => fired = true;
d.SimulateNewDbirthForTest("Plant1","EdgeA","Filler2");
fired.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH"` (+ trailer).
---
## Task 23 (P2): Sparkplug browser tree + `AttributesAsync` + `RequestRebirthAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 24
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (Sparkplug tree Group→EdgeNode→Device→Metric from observed births; `AttributesAsync` = self-describing metric `AttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass}`; `RequestRebirthAsync(scope)` — the **only** publishing session member, scoped to selected group/edge-node, via the `RebirthRequester` path)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (Sparkplug discovery filter `spBv1.0/{groupId}/#`; `OpenAsync` still publishes nothing)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` (dispatch the MQTT-specific `RequestRebirthAsync` for MQTT sessions, gated by the same `DriverOperator` policy that gates the picker; Info-log the target scope)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing tests — birth-driven tree fills; `OpenAsync`/`RootAsync`/`ExpandAsync` publish **nothing**; `RequestRebirthAsync` publishes exactly one scoped NCMD:
```csharp
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(default);
groups.ShouldContain(n => n.BrowseName == "Plant1");
s.PublishCountForTest.ShouldBe(0); // passive
}
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","T", SparkplugDataType.Float);
await s.RequestRebirthAsync(scope: "Plant1/EdgeA");
s.PublishCountForTest.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement. `RequestRebirthAsync` is never fired by `OpenAsync`/`RootAsync`/`ExpandAsync` — only on explicit operator click.
4. Run — expect PASS + `dotnet build` the AdminUI.
5. Commit: `git commit -am "feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action"` (+ trailer).
---
## Task 24 (P2): AdminUI editor Sparkplug mode shape + validator
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 23
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (Sparkplug `Validate`: `groupId`/`edgeNodeId`/`metricName` required, `deviceId` optional, `dataType` a known Sparkplug type; `ToJson` re-derives `FullName` = `{group}/{node}[/{device}]:{metric}`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (Sparkplug field group under the mode switch)
- Modify: `tests/Server/.../MqttTagConfigModelTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug validation + FullName derivation:
```csharp
[Fact]
public void Validate_SparkplugMissingMetricName_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""").Validate().ShouldNotBeEmpty();
[Fact]
public void ToJson_Sparkplug_DerivesFullName()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float"}""")
.ToJson().ShouldContain("Plant1/EdgeA/Filler1:Temperature");
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS + AdminUI build.
5. Commit: `git commit -am "feat(mqtt): typed tag editor Sparkplug mode + validation"` (+ trailer).
---
## Task 25 (P2): C# edge-node simulator fixture + §3.6 live matrix
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/` (a project-owned C# edge-node simulator using the **same** MQTTnet-5 + Tahu-proto path the driver uses — publishes NBIRTH/DBIRTH → periodic N/DDATA; honours a rebirth NCMD; injects seq-gap / alias-reuse / N/DDEATH on demand)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (add the simulator container against the same TLS+auth Mosquitto)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugLiveTests.cs` (category `LiveIntegration`, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts **no NCMD published by open/browse**; explicit `RequestRebirthAsync` node-vs-group enumeration)
### Steps
1. Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
2. Write env-gated live tests covering the §3.6 matrix + browser passivity.
3. Offline skip proof:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
```
4. Deploy + run live:
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt sparkplug
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix"` (+ trailer).
---
## Task 26 (P2): Live `/run` verify Sparkplug — **P2 COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT/Sparkplug P1+P2 code-complete + live-verified)
- Modify: `CLAUDE.md` (if a driver-fleet or endpoint fact changed; propagate to `../scadaproj/CLAUDE.md` OtOpcUa entry per the cross-repo rule)
### Steps
1. On docker-dev (`:9200`): author an `Mqtt` driver (SparkplugB mode) against the simulator; browse the birth-driven picker, click **Request rebirth**, pick a metric, deploy.
2. Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
3. Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
4. Update tracking + (if needed) CLAUDE.md/scadaproj index.
5. Commit: `git commit -am "docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete"` (+ trailer).
---
## Deferred / out of scope
- **Write-through (P3 in the design):** `IWritable` — Sparkplug NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo (mirrors Galaxy fire-and-forget), `WriteIdempotent` default-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row). `MqttDriver` ships **read/subscribe/discover only** in P1+P2 — its absence of `IWritable` means nodes materialize read-only.
- **`DataSet`/`Template` Sparkplug metrics** — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.
- **`Bytes`/`File` metrics** beyond a base64/raw-String fallback. §3.5.
- **EMQX broker** — Mosquitto is the default fixture; EMQX is the documented alternative when a dashboard/built-in Sparkplug tooling helps (§9), not built here.
## Notes on ordering / parallelism
- **P2 is blocked on the P1 milestone (Task 14).** Every P2 task's `blockedBy` chain roots at Task 14 so plain MQTT ships and is live-verified before Sparkplug work begins — and MQTTnet-5/net10 is proven (Task 0) before any proto/Sparkplug effort.
- Genuine parallel pairs: 5‖6, 8‖10, 11‖12, 16‖17, 18‖19, 23‖24.
- DRY: reuse `EquipmentTagRefResolver<MqttTagDefinition>` (as Modbus/OpcUaClient do), the shared `JsonSerializerOptions`, and one `SparkplugCodec` for both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.
@@ -0,0 +1,34 @@
{
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
"tasks": [
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
],
"lastUpdated": "2026-07-24"
}
+771
View File
@@ -0,0 +1,771 @@
# MTConnect Agent Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a read-only, browsable `DriverType = "MTConnect"` Equipment-kind driver (P1 Agent MVP) that surfaces an MTConnect Agent's `/probe` device model as OPC UA nodes and serves live values via `/current` (read) + `/sample` long-poll (subscribe), with browse coming free from the Wave-0 universal discovery browser.
**Architecture:** One `MTConnectDriver` per instance implements `IDriver`, `ITagDiscovery` (`SupportsOnlineDiscovery=true`, `RediscoverPolicy=Once`), `IReadable`, `ISubscribable`, `IHostConnectivityProbe`, `IRediscoverable`**not** `IWritable` (Agent surface is read-only). A thin `IMTConnectAgentClient` seam wraps the HTTP/XML transport (probe/current/sample) so the whole driver is unit-tested against canned XML with no network. A per-instance `MTConnectObservationIndex` holds `dataItemId → latest DataValueSnapshot`, updated by `/current` and the `/sample` pump; `UNAVAILABLE → BadNoCommunication`. Browse is served by the already-shipped `DiscoveryDriverBrowser` — this driver ships **no browser code**, only the `SupportsOnlineDiscovery` opt-in.
**Tech Stack:** `HttpClient` behind the `IMTConnectAgentClient` seam — **TrakHound MTConnect.NET (`-Common` + `-HTTP`, MIT, netstandard2.0) pending the Task 0 verification, else a hand-rolled `System.Xml.Linq` + multipart boundary-reader fallback (drop-in behind the same seam)**; `System.Text.Json` for config DTOs (enum-as-name, `JsonStringEnumConverter` on the probe, `ParseEnum<T>` on the factory); the Wave-0 universal browser seam (`ITagDiscovery.SupportsOnlineDiscovery`).
**Source design:** docs/plans/2026-07-15-mtconnect-driver-design.md
**Program context:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures)
**Wave-0 dependency:** docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed.
---
## Cross-cutting constraints (apply to every task)
- **Per-op deadline (R2-01 frozen-peer lesson).** Every agent HTTP call carries a bounded deadline: `/probe` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits.
- **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy.
- **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this.
- **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`).
- **Secrets from env/secret-refs.** No agent URL creds committed or logged.
- **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
---
## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (gates the `.csproj` package refs in Task 1)
**Files:**
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task)
The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency:
1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**.
2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v <pin>` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10).
3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved").
**Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `PackageReference`s). Any fail ⇒ **hand-roll fallback**: `HttpClient` + `System.Xml.Linq` for probe/current + a `multipart/x-mixed-replace` boundary reader for sample (~300500 LoC behind the same `IMTConnectAgentClient` seam; the driver above the seam is byte-identical either way). Record the chosen path and the observed version/TFM/license as a one-paragraph **DECISION** note appended to this task in the plan file.
**No test / no commit** for this task (it's a decision + a doc edit). Commit the plan edit with the next task, or standalone:
```bash
git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
```
---
## Task 1: Scaffold the two driver projects + the test project
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (every later task builds on these projects)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the three `<Project Path=...>` lines)
Steps:
- Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet).
- The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus).
- The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…`).
- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~2992 pattern).
Verify:
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj # expect: PASS (empty compile)
```
```bash
git add -A && git commit -m "build(mtconnect): scaffold Contracts+Driver+Tests projects, add to slnx (Task 1)"
```
---
## Task 2: `MTConnectDriverOptions` + `MTConnectTagDefinition` in `.Contracts`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`
Mirror `ModbusDriverOptions`: strongly-typed record/POCO holding `AgentUri` (required), optional `DeviceName` scope, `RequestTimeoutMs`, `SampleIntervalMs`, `SampleCount`, `HeartbeatMs`, a `Probe` sub-record (mirror `ModbusProbeOptions`: `Enabled`/`Interval`/`Timeout`), and a `Reconnect` sub-record (`MinBackoffMs`/`MaxBackoffMs`/multiplier — mirror `ModbusReconnectOptions`). `MTConnectTagDefinition`: one record per authored tag — `FullName` (= `dataItemId`), `DriverDataType`, `IsArray`, `ArrayDim`, plus `mtCategory`/`mtType`/`mtSubType`/`units` metadata.
TDD — write the failing test first (defaults + required-field shape):
```csharp
[Fact]
public void Options_default_sample_and_heartbeat_are_sane()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.SampleIntervalMs.ShouldBeGreaterThan(0);
o.HeartbeatMs.ShouldBeGreaterThan(0);
o.Probe.Enabled.ShouldBeTrue();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverOptionsTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver options + tag definition records (Task 2)"
```
---
## Task 3: `MTConnectDataTypeInference.Infer` + golden type-map test
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs`
Pure `static DriverDataType Infer(string category, string? type, string? units, string? representation)` implementing the §3.3 table. It lives in `.Contracts` so the driver, browser-commit, and the typed editor all agree. Return `(DriverDataType, bool isArray, uint? arrayDim)` (or a small record) so `TIME_SERIES` can carry `IsArray=true` + declared `sampleCount`.
Golden table (design §3.3):
| category / shape | result |
|---|---|
| `SAMPLE` numeric with `units` | `Float64` |
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`, `ArrayDim=sampleCount` or null) |
| `EVENT` numeric type (`PartCount`, `Line`) | `Int64` |
| `EVENT` controlled-vocab (`Execution`, `ControllerMode`, `Availability`) | `String` |
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` |
TDD — one `[Theory]` covering every row (golden):
```csharp
[Theory]
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(8u);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDataTypeInferenceTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): pure data-type inference table + golden test (Task 3)"
```
---
## Task 4: Capture the canned XML fixtures
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml`
These canned docs drive the bulk of unit coverage with no network (design §8.1). Capture from a demo agent (`https://demo.mtconnect.org/probe` / `/current` / `/sample`) or hand-author minimal valid MTConnect 1.x XML covering:
- `probe.xml` — one `MTConnectDevices` with a `Device` → nested `Component``DataItem`s spanning every §3.3 category (a `SAMPLE` w/ units, a `TIME_SERIES` w/ `sampleCount`, an `EVENT` numeric, an `EVENT` controlled-vocab, a `CONDITION`), each with a distinct `id`.
- `current.xml` — an `MTConnectStreams` with a `Header instanceId=... nextSequence=...` and one observation per data item, including at least one `<... >UNAVAILABLE</...>`.
- `sample.xml` — an `MTConnectStreams` chunk with several observations and a `Header nextSequence` that continues contiguously from `current.xml`.
- `sample-gap.xml` — a `Header` whose `firstSequence` is **newer** than the `from` the driver would request (the ring-buffer-overflow / forced-`nextSequence`-gap case Task 7 + Task 11 assert re-baseline on).
- `current-unavailable.xml` — every observation `UNAVAILABLE` (Task 8's quality-mapping fixture).
No code, no test yet — verify the files copy to bin:
```bash
dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests # PASS; then confirm bin/.../Fixtures/*.xml exist
git add -A && git commit -m "test(mtconnect): canned probe/current/sample XML fixtures incl. forced-gap (Task 4)"
```
---
## Task 5: `IMTConnectAgentClient` seam + return DTOs
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs` (parsed model + observation records)
Define the seam the whole driver is tested behind:
```csharp
public interface IMTConnectAgentClient
{
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
}
```
`MTConnectProbeModel` — devices → components (nested) → data items (`Id`, `Name?`, `Category`, `Type`, `SubType?`, `Units?`, `Representation?`, `SampleCount?`). `MTConnectStreamsResult``long InstanceId`, `long NextSequence`, `long FirstSequence`, and `IReadOnlyList<MTConnectObservation>` (`DataItemId`, `Value` (string or `"UNAVAILABLE"`), `TimestampUtc`). These DTOs are the neutral shape both the TrakHound adapter and the hand-roll fallback produce, so the driver never sees TrakHound types (or `XElement`) directly.
Verify (compiles only):
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect # PASS
git add -A && git commit -m "feat(mtconnect): IMTConnectAgentClient seam + neutral parse DTOs (Task 5)"
```
---
## Task 6: `MTConnectAgentClient` — parse `/probe` into the device model
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (probe leg only this task)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs`
Implement `ProbeAsync` — either via TrakHound's `MTConnectHttpClient` (map its `IDevice`/`IComponent`/`IDataItem` into the neutral `MTConnectProbeModel`) or the hand-roll `System.Xml.Linq` walk of `probe.xml`. The `/probe` call carries the linked-CTS deadline. To keep the parse itself unit-testable without a socket, factor the byte→model parse into an internal static (`MTConnectProbeParser.Parse(Stream|string)`) and feed it the fixture directly.
TDD:
```csharp
[Fact]
public async Task Probe_parse_yields_nested_components_and_all_dataitems()
{
var xml = await File.ReadAllTextAsync("Fixtures/probe.xml");
var model = MTConnectProbeParser.Parse(xml);
model.Devices.ShouldHaveSingleItem();
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
ids.ShouldContain("<the CONDITION dataItem id from the fixture>");
model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectProbeParseTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): agent client /probe parse to device model (Task 6)"
```
---
## Task 7: `MTConnectAgentClient` — parse `/current` + `/sample`, detect the sequence gap
**Classification:** high-risk (ring-buffer / sequence paging correctness)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (add current/sample legs + `MTConnectStreamsParser`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs`
`CurrentAsync` parses `current.xml``MTConnectStreamsResult` (Header `instanceId`/`nextSequence`/`firstSequence` + observations). `SampleAsync(from, ct)` yields one `MTConnectStreamsResult` per multipart chunk and **exposes the gap signal**: expose a helper `bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk) => chunk.FirstSequence > requestedFrom` so Task 11's pump can re-baseline. Advance the caller's `from` to `chunk.NextSequence` (contiguous). Chunk framing (the multipart boundary reader) is TrakHound-internal or the hand-roll's boundary reader — either way the parser is fed one chunk's XML.
TDD (the forced-gap fixture is the load-bearing case):
```csharp
[Fact]
public void Current_parse_reads_header_sequences_and_observations()
{
var r = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml"));
r.NextSequence.ShouldBeGreaterThan(0);
r.Observations.ShouldNotBeEmpty();
}
[Fact]
public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
{
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/sample-gap.xml"));
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectStreamsParseTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
```
---
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs`
Thread-safe `dataItemId → DataValueSnapshot` map updated by `/current` and `/sample`. The snapshot builder maps a raw observation → `DataValueSnapshot`:
- value `UNAVAILABLE``new(null, BadNoCommunication, ts, now)` where `private const uint BadNoCommunication = 0x80310000u;` (design §3.3 — declared as a const, the Modbus `StatusBadCommunicationError` pattern; renders by name in the CLI's `SnapshotFormatter`).
- a present value → coerced to the tag's `DriverDataType` with `StatusCode = Good (0)`, `SourceTimestampUtc = observation.timestamp`.
- a `dataItemId` never seen / empty condition → `Bad`-coded snapshot.
TDD (drive off `current.xml` + `current-unavailable.xml`):
```csharp
[Fact]
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
{
var idx = new MTConnectObservationIndex();
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current-unavailable.xml")));
var snap = idx.Get("<a dataItemId from the fixture>");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(0x80310000u);
}
[Fact]
public void Present_value_indexes_good_with_source_timestamp()
{
var idx = new MTConnectObservationIndex();
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml")));
var snap = idx.Get("<a good dataItemId>");
snap.StatusCode.ShouldBe(0u);
snap.SourceTimestampUtc.ShouldNotBeNull();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectObservationIndexTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)"
```
---
## Task 9: `MTConnectDriver` shell — `IDriver` lifecycle
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (IDriver members only this task)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs`
Ctor `MTConnectDriver(MTConnectDriverOptions options, string driverInstanceId, Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null, ILogger<MTConnectDriver>? logger = null)`**connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement:
- `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure.
- `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed).
- `ShutdownAsync`: stop stream, dispose client.
- `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)``LastSuccessfulRead` = last `/current`|`/sample` chunk time.
- `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index.
TDD with a canned-XML fake client:
```csharp
[Fact]
public async Task Initialize_primes_index_and_reports_healthy()
{
var fake = CannedAgentClient.FromFixtures(); // test helper: serves probe.xml + current.xml
var d = new MTConnectDriver(Opts(), "mt1", _ => fake);
await d.InitializeAsync("{\"agentUri\":\"http://x\"}", default);
d.GetHealth().State.ShouldBe(DriverState.Healthy);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverLifecycleTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)"
```
---
## Task 10: `IReadable.ReadAsync``/current`, ordered snapshots
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 12, Task 13
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs`
`ReadAsync(fullReferences, ct)`: one `/current` under the per-call deadline, index the whole-device response, return **one `DataValueSnapshot` per requested ref in order**; a ref absent from the response → a `Bad`-coded snapshot (never a throw). Batch reads cost one round-trip.
TDD:
```csharp
[Fact]
public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
{
var d = await InitializedDriver();
var res = await d.ReadAsync(new[] { "<good id>", "does-not-exist" }, default);
res.Count.ShouldBe(2);
res[0].StatusCode.ShouldBe(0u);
(res[1].StatusCode & 0x80000000u).ShouldBe(0x80000000u); // Bad
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectReadTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)"
```
---
## Task 11: `ISubscribable``/sample` long-poll pump + ring-buffer re-baseline
**Classification:** high-risk (streaming state + re-baseline correctness)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs` (`ISubscriptionHandle`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs`
- `SubscribeAsync`: on the **first** subscription, start the shared sample stream from `nextSequence`; record the subscribed ref set; immediately fire `OnDataChange` for each subscribed ref from the primed `/current` (initial-data convention); return a handle (monotonic id + `DiagnosticId`). The stream-start handshake must complete inside the invoker's Subscribe timeout; the long-lived pump then runs under the heartbeat watchdog, not that timeout.
- Pump: each chunk → for each observation whose `dataItemId ∈` subscribed set, update the index + raise `OnDataChange(handle, dataItemId, snapshot)`; advance `from = nextSequence`.
- **Ring-buffer overflow:** when `IsSequenceGap(from, chunk)` (Task 7) → re-`/current` to re-baseline, then resume from the new `nextSequence`.
- `UnsubscribeAsync`: drop the handle's refs; stop the shared stream when the set empties.
TDD — assert the initial-data callback AND that a forced gap triggers a re-baseline `/current` (drive `SampleAsync` to yield `sample-gap.xml`):
```csharp
[Fact]
public async Task Subscribe_fires_initial_data_from_current()
{
var d = await InitializedDriver();
var seen = new List<string>();
d.OnDataChange += (_, e) => seen.Add(e.FullReference);
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(500), default);
seen.ShouldContain("<good id>");
}
[Fact]
public async Task Sequence_gap_triggers_recurrent_current_rebaseline()
{
var fake = CannedAgentClient.WithGapThenResume(); // sample-gap.xml then a contiguous chunk
var d = await InitializedDriver(fake);
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
await fake.PumpOnce();
fake.CurrentCallCount.ShouldBeGreaterThan(1); // initial prime + re-baseline
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectSubscribeTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)"
```
---
## Task 12: `ITagDiscovery.DiscoverAsync` + `SupportsOnlineDiscovery=true`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 13
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs`
`public bool SupportsOnlineDiscovery => true;` + `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;` (probe is synchronous + complete — this one member override is the whole browse opt-in; the Wave-0 universal browser does the rest). `DiscoverAsync(builder, ct)` streams the cached/`/probe` model into the builder:
- each `Device``builder.Folder(name, name)` → child builder;
- each nested `Component` → recursive `Folder(name, displayName)` on the parent's child builder;
- each `DataItem``child.Variable(browseName, displayName, attr)` with `browseName = dataItem.Name ?? dataItem.Id`, and
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: <Infer(...).DataType>, IsArray: rep==TIME_SERIES, ArrayDim: <declared sampleCount or null>, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`.
- If `DeviceName` is set, stream only that device's subtree.
**Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction.
TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable):
```csharp
[Fact]
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
{
var d = await InitializedDriver();
var cap = new CapturingBuilder();
await d.DiscoverAsync(cap, default);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("<CONDITION dataItem id>");
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
d.SupportsOnlineDiscovery.ShouldBeTrue();
d.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDiscoverTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)"
```
---
## Task 13: `IHostConnectivityProbe` + `IRediscoverable` (instanceId watch)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 12
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs`
- `IHostConnectivityProbe`: one `HostConnectivityStatus` per Agent (`HostName = AgentUri`); a cheap periodic `/probe` (or the sample-stream heartbeat) flips `Running ↔ Stopped`; raise `OnHostStatusChanged` on transition. Enable/interval from `MTConnectDriverOptions.Probe`.
- `IRediscoverable`: cache `Header.instanceId` from Initialize; on every `/current`/`/sample` chunk, a **changed** `instanceId` means the Agent restarted / its model changed → raise `OnRediscoveryNeeded(new("MTConnect agent instanceId changed", null))`. This is why `RediscoverPolicy = Once` is safe — instanceId change, not polling, drives re-discovery.
TDD:
```csharp
[Fact]
public async Task InstanceId_change_raises_rediscovery()
{
var fake = CannedAgentClient.WithInstanceIdChangeOnNextChunk();
var d = await InitializedDriver(fake);
RediscoveryEventArgs? got = null;
((IRediscoverable)d).OnRediscoveryNeeded += (_, e) => got = e;
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
await fake.PumpOnce();
got.ShouldNotBeNull();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectHostAndRediscoverTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13)"
```
---
## Task 14: `MTConnectDriverProbe : IDriverProbe`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 15
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs`
Mirror `ModbusDriverProbe`: `DriverType => "MTConnect"`; parse the config DTO with a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` (copy `ModbusDriverProbe._opts` — the enum-serialization gotcha); one-shot `GET {AgentUri}/probe` under `timeout` (linked-CTS); `Ok=true`+latency on any valid `MTConnectDevices` response; `Ok=false`+message on TCP/HTTP/timeout failure. **Never throws** (`IDriverProbe` contract).
TDD (unit — parse + never-throw; live reachability is Task 20's integration job):
```csharp
[Fact]
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Probe_on_blank_config_returns_not_ok()
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverProbeTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)"
```
---
## Task 15: `MTConnectDriverFactoryExtensions`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 14
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
Copy the Modbus factory: `public const string DriverTypeName = "MTConnect";` `Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)``registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory))`. `CreateInstance` deserializes an `MTConnectDriverConfigDto` (nullable-init DTO with `AgentUri`, `DeviceName`, timeouts, `Tags`, `Probe`), validates `AgentUri` present, builds `MTConnectDriverOptions`, returns `new MTConnectDriver(options, id, agentClientFactory: null, logger: loggerFactory?.CreateLogger<MTConnectDriver>())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum<T>`).
TDD:
```csharp
[Fact]
public void Factory_builds_a_driver_from_minimal_config()
{
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
d.DriverType.ShouldBe("MTConnect");
d.DriverInstanceId.ShouldBe("mt1");
}
[Fact]
public void Factory_rejects_config_without_agentUri()
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectFactoryTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver factory + config DTO (Task 15)"
```
---
## Task 16: Host registration + `DriverTypeNames.MTConnect` + guard-test parity
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `MTConnect` const + append to `All`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (Register call + probe alias + `TryAddEnumerable`)
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj` (add ProjectReference to `Driver.MTConnect`)
Three coupled edits that **must land together** to keep `DriverTypeNamesGuardTests` green (it asserts exact-set parity between `DriverTypeNames.All` and the reflection-discovered registered factories, scanning `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in the test's bin):
1. Add `public const string MTConnect = "MTConnect";` to `DriverTypeNames` and append it to `All` (design uses `DriverTypeNames.MTConnect` in the editor map/validator per the CLAUDE.md "keyed off constants" rule).
2. In `DriverFactoryBootstrap`: add `using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;`, `Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)`, and `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw).
3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.**
Verify (the guard test is the gate here):
```bash
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~DriverTypeNamesGuardTests" # RED before the ProjectReference/Register land together, GREEN after
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host # PASS
git add -A && git commit -m "feat(mtconnect): host registration + DriverTypeNames const + guard parity (Task 16)"
```
---
## Task 17: AdminUI typed model `MTConnectTagConfigModel`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs`
- Test: `tests/Server/<AdminUI test project>/MTConnectTagConfigModelTests.cs` (mirror the Modbus model test's location)
Copy `ModbusTagConfigModel`: pure `FromJson`/`ToJson`/`Validate`, **preserves unknown keys** via the `TagConfigJson` bag. Fields: `FullName` (dataItemId, required), `MtCategory`/`MtType`/`MtSubType` (from probe — read-only in UI), `DataType` (`DriverDataType` override), `Units`, `MtDevice`/`MtComponent` (author context). `ToJson` writes enums via `TagConfigJson.Set` (**name strings** — the enum-serialization trap). `Validate()` returns an error when `FullName` is blank.
TDD:
```csharp
[Fact]
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
{
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
var m = MTConnectTagConfigModel.FromJson(json);
var outJson = m.ToJson();
outJson.ShouldContain("\"somethingUnknown\":42");
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
}
[Fact]
public void Validate_blocks_blank_fullname()
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
```
```bash
dotnet test tests/Server/<AdminUI test project> --filter "FullyQualifiedName~MTConnectTagConfigModelTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): typed AdminUI tag-config model (Task 17)"
```
---
## Task 18: AdminUI editor razor + map/validator registration
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs`
Thin razor shell over `MTConnectTagConfigModel` (mirror `ModbusTagConfigEditor.razor`): `FullName` text, read-only `mtCategory`/`mtType`, a `DataType` override `<select>`. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface. Register:
- `[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)` in `TagConfigEditorMap`.
- `[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()` in `TagConfigValidator`.
(No AdminUI `IDriverBrowser` DI line — browse is the universal browser, already registered.)
Verify (AdminUI has no bUnit — Razor binding is validated live in Task 21; here just compile):
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI # PASS
git add -A && git commit -m "feat(mtconnect): typed tag editor razor + map/validator entries (Task 18)"
```
---
## Task 19: `mtconnect/cppagent` docker fixture
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 17, Task 18
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg` (if the image needs it)
Compose one `mtconnect/cppagent` service, **`project: lmxopcua` label on the service** (host-side `lmxopcua-fix` convention), seeded with a canned `Devices.xml` (+ the image's built-in adapter/simulator so `/current` returns live-ish data), exposed on the shared docker host `10.100.0.35`. Deploy via `lmxopcua-fix sync mtconnect` + `lmxopcua-fix up mtconnect` (repo `Docker/` is source of truth; `/opt/otopcua-mtconnect/` is the mirror). Model the compose on the Modbus fixture's shape.
No .NET test in this task (fixture only). Verify the compose parses:
```bash
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml config # PASS
git add -A && git commit -m "test(mtconnect): cppagent docker fixture + seeded Devices.xml (Task 19)"
```
---
## Task 20: Env-gated integration suite against cppagent
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
A `*.IntegrationTests` suite that reads the agent endpoint from an env var (e.g. `MTCONNECT_AGENT_ENDPOINT`, default `http://10.100.0.35:5000`) and **skips cleanly when unset/down** (the Modbus/S7 pattern — `Skip.If(...)` or a fixture-reachability guard). Cover the real HTTP round-trips a canned fixture can't: `MTConnectDriverProbe` reachability green, `InitializeAsync` + `DiscoverAsync` build a non-empty tree, `ReadAsync` returns live values, `SubscribeAsync` delivers at least one `OnDataChange` from the live `/sample` stream.
Verify it skips offline (safe on macOS with no fixture up):
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests # all tests SKIP when the env var/endpoint is absent
git add -A && git commit -m "test(mtconnect): env-gated cppagent integration suite (Task 20)"
```
---
## Task 21: Live `/run` verify on docker-dev — browse picker, editor, read, subscribe, deploy
**Classification:** high-risk (live; Razor + deploy-inertness bugs pass unit tests)
**Estimated implement time:** ~5 min (driving; excludes fixture spin-up)
**Parallelizable with:** none
**Files:**
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (append a LIVE-GATE RESULT note)
> **DEPENDENCY:** this task's browse-picker leg is gated on the **Wave-0 universal-browser live gate (Gitea #468)** being closed — the picker's Browse button, `DiscoveryDriverBrowser.CanBrowse`, and `CapturedTreeBrowseSession` are Wave-0 machinery this driver only opts into via `SupportsOnlineDiscovery=true`. If #468 is still open, run the read/subscribe/deploy legs and record the browse leg as blocked-on-#468.
Bring the cppagent fixture up (`lmxopcua-fix up mtconnect`), author an MTConnect driver on docker-dev (`http://localhost:9200`, auto-authenticated admin — no login), and verify **in the running AdminUI + against `opc.tcp://localhost:4840`** (per the live-verify discipline — Razor binding bugs pass unit tests):
1. **Test Connect** on the MTConnect driver goes green (probe reaches the fixture).
2. **Browse picker** (`/uns` TagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commits `TagConfig.FullName = <dataItemId>` (Wave-0 dependency).
3. **Typed editor** shows `FullName`/read-only `mtCategory`/`DataType` override and round-trips.
4. **Deploy** the config; via Client.CLI read a picked node (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=…;s=…"`) — value present, `UNAVAILABLE` items render `BadNoCommunication`.
5. **Subscribe** (`... subscribe ...`) — live updates flow from the `/sample` stream.
```bash
git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/subscribe/deploy (Task 21)"
```
---
## Task 22: Docs + deferred-writeback note; update the tracking doc
**Classification:** trivial
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `docs/drivers/MTConnect.md` (or a short section — mirror an existing driver doc)
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (mark MTConnect P1 done + link this plan)
Document the P1 Agent MVP: config keys, browse-via-universal, the `UNAVAILABLE→BadNoCommunication` semantics, CONDITION-as-String, and the fixture recipe. Record **write-back (MTConnect Interfaces) as deferred** — point at `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6 + §9 (P1.5 native-alarm CONDITION, P2 SHDR ingest are also deferred there). Update the driver-expansion tracking doc's Wave-2 row.
```bash
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
```
---
## Deferred (out of P1 — pointers, not scope)
- **Write-back (MTConnect Interfaces)** — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
- **CONDITION → native Part-9 alarms** (`IAlarmSource`), **`TIME_SERIES` SAMPLE arrays** materialized as OPC UA arrays, **EVENT controlled-vocab → OPC UA enumerations** — design §9 P1.5 fast-follow.
- **SHDR adapter ingest** (`SourceMode: "Agent" | "Shdr"`, loses auto-discovery) — design §9 P2.
@@ -0,0 +1,29 @@
{
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
],
"lastUpdated": "2026-07-24"
}
+670
View File
@@ -0,0 +1,670 @@
# SQL Poll Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a read-only `Sql` Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
**Architecture:** Three new projects mirroring the Modbus split — `Driver.Sql.Contracts` (options/tag DTOs + parser, zero transport deps), `Driver.Sql` (the `IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable`/`IHostConnectivityProbe` runtime + `ISqlDialect` seam + `SqlServerDialect`, owning `Microsoft.Data.SqlClient`), and `Driver.Sql.Browser` (schema-walk `IBrowseSession`). The `PollGroupEngine`, `EquipmentTagRefResolver<TDef>`, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a `DbParameter`; identifiers are dialect-quoted from catalog-validated names only.
**Tech Stack:** `Microsoft.Data.SqlClient` (6.1.1, already in `Directory.Packages.props`) via `System.Data.Common` base types behind a `DbProviderFactory`; `ISqlDialect` + `SqlProvider` enum seam (only `SqlServer` implemented in v1); `Microsoft.Data.Sqlite` (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. **Zero new NuGet dependencies.**
**Source design:** docs/plans/2026-07-15-sql-poll-driver-design.md
**Program design (shared contract):** docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
**Conventions for every task below:** all projects `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors` on product projects. Every commit message ends with the repo trailer `Claude-Session: <session-url>` (omitted from the `git commit` examples for brevity). Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` green before each commit. New test projects use `xunit.v3` + `Shouldly` (see `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj`).
---
## Task 0: Scaffold `Driver.Sql.Contracts` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (root of the tree)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project under `/src/Drivers/`)
**Steps:**
1. Create the csproj — refs `Core.Abstractions` ONLY (no transport deps, so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`). `InternalsVisibleTo` the Contracts is not needed. `RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts`.
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
```
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under the `/src/Drivers/` folder, beside the `Driver.Modbus.Contracts` line.
3. Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS (empty project compiles).
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"`
---
## Task 1: `SqlProvider` + `SqlTagModel` enums + `SqlDriverConfigDto` (string-enum round-trip)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocks most downstream)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*` is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. **Instead**, put this task's test in a throwaway `Contracts`-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. **This task ships DTOs only; its guard is Task 9.**
> **Note:** Tasks 12 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, **reorder locally**: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The `blockedBy` graph permits this. The concrete failing-test-first discipline begins at Task 2.
**Steps:**
1. `SqlProvider.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel { KeyValue, WideRow, Query }
```
2. `SqlDriverConfigDto.cs` — the driver-config blob shape (§5.1). Enum-typed fields (`Provider`) so the string converter round-trips names; timeouts as `TimeSpan?`; `ConnectionStringRef` (NOT the connection string). Fields: `Provider`, `ConnectionStringRef`, `DefaultPollInterval`, `OperationTimeout`, `CommandTimeout`, `MaxConcurrentGroups`, `NullIsBad`, `AllowWrites`, `Probe` (`SqlProbeDto{Enabled, Interval}`). `[JsonStringEnumConverter]` is applied at the factory (Task 9), not on the DTO.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"`
---
## Task 2: `SqlTagDefinition` + `SqlEquipmentTagParser.TryParse` (strict enum reads, malicious-input safe)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs` (Tests project scaffolded in Task 6 — **pull the csproj creation forward from Task 6 for this task**, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
**TDD:**
1. **Failing test** — parse a KeyValue blob and a malicious `keyValue`; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd `model` enum rejects:
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
public class SqlEquipmentTagParserTests
{
[Fact]
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
{
var json = """
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
def.DeclaredType.ShouldBe(DriverDataType.Float64);
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
}
```
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests` — expect FAIL (types don't exist).
3. **Minimal impl**`SqlTagDefinition` record (`Name` = RawPath, `Model`, `Table`, `KeyColumn`, `KeyValue`, `ValueColumn`, `TimestampColumn`, `ColumnName`, `RowSelectorColumn`, `RowSelectorValue`, `RowSelectorTopByTimestamp`, `DeclaredType` = `DriverDataType?`). `SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)` — mirror `ModbusTagDefinitionFactory.FromTagConfig`: recognize by leading `{`, read the `model` discriminator via `TagConfigJson.TryReadEnumStrict` (a present-but-invalid enum → `false` → the driver surfaces `BadNodeIdUnknown`, per R2-11), read model-specific fields, read the optional `type` override strictly, set `Name = rawPath`. Wrap in try/catch(JsonException/FormatException) → `false`. **No SQL is built here** — the parser only captures strings.
4. Run test — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"`
---
## Task 3: Scaffold `Driver.Sql` runtime project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. Create the csproj per design §2.1 — refs `Core.Abstractions` + `Core` (for `DriverFactoryRegistry` in `ZB.MOM.WW.OtOpcUa.Core.Hosting`) + `Driver.Sql.Contracts`; packages `Microsoft.Data.SqlClient` + `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` the `.Tests`.
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql runtime project"`
---
## Task 4: `ISqlDialect` seam + `SqlServerDialect` (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
**Classification:** high-risk (identifier quoting is the injection boundary)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs`
**TDD:**
1. **Failing test** — golden catalog SQL, `QuoteIdentifier` escape + reject, `MapColumnType` table:
```csharp
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`ISqlDialect` per design §2.2 (`Provider`, `Factory` = `DbProviderFactory`, `QuoteIdentifier`, `LivenessSql`, `ListSchemasSql`, `ListTablesSql`, `ListColumnsSql`, `MapColumnType`). `SqlServerDialect`: `Factory => SqlClientFactory.Instance`; `QuoteIdentifier` doubles `]`, rejects embedded NUL/control chars via `ArgumentException`; catalog SQL uses `INFORMATION_SCHEMA` with `@schema`/`@table` markers; `MapColumnType` folds SQL type families to `DriverDataType` per design §3.7 (`decimal`/`numeric`/`money``Float64` with the documented precision caveat as an XML remark).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"`
---
## Task 5: `SqlQueryPlan` grouping + parameter binding (pure, no DB)
**Classification:** high-risk (the query-mapping core; GroupKey correctness governs correctness of every read)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs`
**TDD:**
1. **Failing test** — KeyValue tags on the same `(table,keyColumn,valueColumn,timestampColumn)` fold into ONE plan whose SQL is `... WHERE [tag_name] IN (@k0,@k1)` with two bound params; WideRow tags on the same `(table,rowSelector)` fold into one `SELECT <cols> ... WHERE [station_id]=@w`; different tables → different plans:
```csharp
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[] {
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }` (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). `SqlGroupPlanner.Plan(tags, dialect)`: KeyValue → GroupKey `(table,keyColumn,valueColumn,timestampColumn)`, emit `SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w` (or `ORDER BY <col> DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `<table>` split on `.` and each part quoted. **Every value is a param; zero interpolation.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"`
---
## Task 6: Scaffold `Driver.Sql.Tests` + `SqliteDialect` (test) + `SqlitePollFixture`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 12
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
> If Task 2 already created the csproj, this task adds the `SqliteDialect`/fixture and the slnx entry only.
**Steps:**
1. csproj — `xunit.v3` + `Shouldly` + `Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`; `PackageReference Microsoft.Data.Sqlite` (10.0.7, test-only — no product dep on SQLite); project-refs `Driver.Sql` + `Driver.Sql.Contracts`. Add to slnx under `/tests/Drivers/`.
2. `SqliteDialect : ISqlDialect``Provider = SqlServer` shim is wrong; add a test-only `Provider` value is not needed — implement `ISqlDialect` directly with `Factory => SqliteFactory.Instance`, `QuoteIdentifier` doubling `"`, `LivenessSql = "SELECT 1"`, catalog SQL over `sqlite_schema` + `PRAGMA table_info(...)`, `MapColumnType` mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via `<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />`.
3. `SqlitePollFixture` — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table `TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)` and a wide-row `LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)` with a few rows. Exposes the open `SqliteConnection` + a `DbProviderFactory` shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
4. Build + run (no tests yet) — expect PASS/empty.
5. Commit: `git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"`
---
## Task 7: `SqlPollReader` core — one query per group, bounded deadline, slice back in input order
**Classification:** high-risk (connection/timeout/result-slicing core)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs`
**TDD:**
1. **Failing test** (against `SqlitePollFixture`) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → `BadNoData`, NULL cell → `Uncertain` (default `nullIsBad=false`), source timestamp from `timestampColumn`:
```csharp
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
var refs = new[] { "Speed", "Missing", "Temp" };
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
snaps.Count.ShouldBe(3);
snaps[0].Value.ShouldBe(42.0);
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
snaps[2].Value.ShouldBeNull();
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlPollReader.ReadAsync(refs, ct)`: resolve each ref → `SqlTagDefinition` (miss → `BadNodeIdUnknown`); `SqlGroupPlanner.Plan(...)`; per group under a `SemaphoreSlim(maxConcurrentGroups)`: `await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt)`; build `DbCommand` with `CommandTimeout = (int)commandTimeout.TotalSeconds` and bound params; `using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token)`; index rows by key/selector; map each member's cell → `DataValueSnapshot` (type per `DeclaredType` ?? `dialect.MapColumnType`-inferred; NULL → `nullIsBad ? Bad : Uncertain`; absent key → `BadNoData`; source timestamp from `timestampColumn` else read wall-clock). **Reassemble in input order** (`DataValueSnapshot[refs.Count]`). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). `await using` both connection and reader — never leak. Add `SqlStatusCodes` static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
4. Run — expect PASS. Add a NULL-cell test with `nullIsBad:true``Bad`.
5. Commit: `git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"`
---
## Task 8: `SqlDriver` shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
**Classification:** high-risk (lifecycle + poll-engine wiring + connection validation)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs`
**TDD:**
1. **Failing test** — construct `SqlDriver` with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored `RawTagEntry` list; `InitializeAsync``Healthy`; `DiscoverAsync` streams one Variable per authored tag with `SecurityClass=ViewOnly` + `RediscoverPolicy=Once` + `SupportsOnlineDiscovery=false`; `ReadAsync` delegates to the reader; a subscribe fires an initial change:
```csharp
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var cap = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable`. Mirror `ModbusDriver`: fields grouped up top; `_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...)`; `_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s)`. `InitializeAsync`: build `_tagsByRawPath` from `_options.RawTags` via `SqlEquipmentTagParser.TryParse` (miss = logged skip, never throw); open ONE connection, run `dialect.LivenessSql` under `CommandTimeout` + linked CTS, dispose; success → `Healthy`, failure → `Faulted` + `LastError`. `ReadAsync``_reader.ReadAsync`. `SubscribeAsync``_poll.Subscribe`; `UnsubscribeAsync``_poll.Unsubscribe`. `IHostConnectivityProbe`: single logical host (server host from the connection string) with `Running/Stopped` from the last poll/liveness outcome + `OnHostStatusChanged` on transitions. `GetMemoryFootprint => 0`; `FlushOptionalCachesAsync => Task.CompletedTask`; `ReinitializeAsync` = teardown+init. `DriverType => DriverTypeNames.Sql`. Add a `ForTest(...)` internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). **Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"`
---
## Task 9: `SqlDriverFactoryExtensions` + `connectionStringRef` env resolution + enum-serialization guard
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs`
**TDD:**
1. **Failing test**`CreateInstance` with a config carrying `"provider":"SqlServer"` builds a driver; a missing `connectionStringRef` throws a clean error; the connection-string ref resolves from env `Sql__ConnectionStrings__<ref>`; **enum-serialization guard** — a config author serializing `provider` as a NUMBER still parses (`JsonStringEnumConverter` accepts ordinals) AND the round-trip writes the NAME:
```csharp
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlConnectionStringResolver.Resolve(string @ref)``Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")` (direct env read, deliberate — the factory closure has no `IConfiguration`, per design §8.2), throws actionable if absent. `SqlDriverFactoryExtensions`: `const DriverTypeName = DriverTypeNames.Sql`; `Register(registry, loggerFactory)``registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory))`; `CreateInstance` deserializes `SqlDriverConfigDto` with `JsonOptions` carrying `JsonStringEnumConverter` + `UnmappedMemberHandling.Skip`, validates `ConnectionStringRef` present, builds `SqlServerDialect` from `Provider` (P1: only `SqlServer` constructible; others throw "provider not available in this build"), resolves the connection string, constructs the `SqlPollReader` + `SqlDriver`. **Never log the resolved connection string** (log provider + server host + database only). Expose `JsonOptionsForTest` internal.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"`
---
## Task 10: `SqlDriverProbe` (SELECT 1 liveness, bounded timeout)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 9, Task 12
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs`
**TDD:**
1. **Failing test** — against the SQLite fixture (inject dialect+factory), `ProbeAsync` returns green with latency; a bad connection string returns a red result with the error message (never throws):
```csharp
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
r.Success.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverProbe : IDriverProbe`, `DriverType => DriverTypeNames.Sql`. Parse config via the SAME factory DTO shape + `JsonStringEnumConverter` (R2-11 factory parity, mirroring `ModbusDriverProbe`), resolve the connection string ref, open a connection under a linked CTS bounded by `timeout`, run `dialect.LivenessSql` (`SELECT 1`), return `DriverProbeResult(true, "SQL SELECT 1 OK", latency)`; catch → red result naming the failure. Never throws. `ForTest` injects factory+dialect for the SQLite path.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"`
---
## Task 11: Host registration — `DriverTypeNames.Sql` + factory + probe + guard test
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (project-ref `Driver.Sql` if not transitively present)
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs` is EXISTING — it asserts bidirectional parity between `DriverTypeNames` constants and the factory-registered set; this task makes it pass by adding both sides.
**TDD:**
1. **Failing step** — add `public const string Sql = "Sql";` to `DriverTypeNames` + include in `All`, but DON'T yet register the factory. Run `DriverTypeNamesGuardTests` → expect FAIL (constant with no registered factory).
2. **Impl** — in `DriverFactoryBootstrap.Register(...)` add `Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);`; in `AddOtOpcUaDriverProbes` add `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());` with a `using SqlProbe = Driver.Sql.SqlDriverProbe;` alias; add the `Driver.Sql` project-ref to the Host csproj. Default tier `DriverTier.A` (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so `ShouldStub()` needs no change).
3. Run guard test + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS.
4. Commit: `git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"`
---
## Task 12: Scaffold `Driver.Sql.Browser` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 6, Task 9, Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. csproj refs `Commons` (`ZB.MOM.WW.OtOpcUa.Commons``IDriverBrowser`/`IBrowseSession`/`BrowseNode`) + `Driver.Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is the deliberate deviation from `OpcUaClient.Browser` recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. `InternalsVisibleTo` the `.Browser.Tests`.
2. Add to slnx under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Browser project"`
---
## Task 13: `SqlBrowseSession` — schema walk over the dialect catalog
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj` (+ slnx entry, + link `SqliteDialect.cs` from Sql.Tests)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs`
**TDD:**
1. **Failing test** — over a seeded SQLite DB + `SqliteDialect`: `RootAsync` yields schema folder(s); `ExpandAsync(schema)` yields the seeded tables; `ExpandAsync(table)` yields columns as leaves; `AttributesAsync(column)` yields the mapped `DriverDataType`:
```csharp
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
var cols = await session.ExpandAsync(tableNode.NodeId, default);
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlBrowseSession : IBrowseSession` (match the `Commons.Browsing` interface shape used by `OpcUaClientBrowseSession`): `RootAsync`/`ExpandAsync(nodeId)`/`AttributesAsync(nodeId)` run the dialect catalog queries with bound `@schema`/`@table` params; encode `NodeId` as `schema` / `schema.table` / `schema.table|column`; a `SemaphoreSlim _gate` serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf `AttributesAsync` returns `AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")` (mirrors Galaxy two-stage pick).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"`
---
## Task 14: `SqlDriverBrowser` — IDriverBrowser open (env ref OR pasted literal, transient connection)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs`
**TDD:**
1. **Failing test**`OpenAsync` with a form JSON whose `connectionStringRef` resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
```csharp
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverBrowser : IDriverBrowser`, `DriverType => DriverTypeNames.Sql`. `OpenAsync(configJson, ct)`: deserialize with the same `JsonStringEnumConverter` options; resolve `connectionStringRef` via a direct env read (`Sql__ConnectionStrings__<ref>`) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal `connectionString` for ad-hoc browse (session-only, **never persisted, never logged, no `_lastConfigJson` cached field**); select `SqlServerDialect` (P1); open ONE transient `DbConnection`, return a `SqlBrowseSession`. Reuses the AdminUI `BrowseSessionRegistry` + reaper + idle TTL unchanged.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"`
---
## Task 15: AdminUI browser DI + `SqlAddressPickerBody.razor` (picker body)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` beside the OpcUaClient/Galaxy lines ~:75)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (project-ref `Driver.Sql.Browser`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor` (reuse `DriverBrowseTree.razor` for the tree + an attribute side-panel + model/selector fields; gated by the existing `DriverOperator` policy; manual entry retained)
**Steps (no unit test — Razor is live-verified in Task 21):**
1. Register the bespoke `IDriverBrowser` (the universal-browser DI line is NOT added for `Sql` — its `CanBrowse` is false and bespoke wins by construction).
2. `SqlAddressPickerBody.razor` — thin body: `DriverBrowseTree` in picker mode + attribute side-panel; on column pick, compose the `TagConfig` blob (DisplayName default `table.column`, prefill `type` from `AttributesAsync`, operator picks model + fills key/row selector; `SecurityClass=ViewOnly`).
3. `dotnet build` — expect PASS.
4. Commit: `git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"`
---
## Task 16: Env-gated central-SQL integration fixture (`Driver.Sql.IntegrationTests`)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 17
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj` (+ slnx entry)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs`
**TDD:**
1. **Failing/skipping test** — against the always-on central SQL Server (`10.100.0.35,14330`), seed a `SqlPollFixture` database with the two sample tables (KeyValue + wide-row), then validate the real `Microsoft.Data.SqlClient` path (read round-trip, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation, pooling). **Env-gated** via `SQL_TEST_ENDPOINT` / a full `Sql__ConnectionStrings__Fixture` — absent ⇒ skip cleanly (use `Assert.Skip(...)` / xunit.v3 `[Fact(Skip=...)]` dynamic skip), like every other `*.IntegrationTests`:
```csharp
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, default);
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
snaps[0].StatusCode.ShouldBe(0u);
}
```
2. Run offline — expect SKIP (green).
3. **Impl** — the fixture (create-if-missing DB + tables + seed rows via `Microsoft.Data.SqlClient`) and the read tests. **The seed DB is `SqlPollFixture`, NOT `ConfigDb`** — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
4. Commit: `git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"`
---
## Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 16
**Files:**
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs`
**TDD:**
1. **Failing test** (offline, SQLite fixture) — a malicious `keyValue` (`'; DROP TABLE TagValues; --`) reads harmlessly as a bound param and the seed table still exists afterward; a malicious `table`/`column` identifier that is not catalog-valid is REJECTED (tag → `BadNodeIdUnknown`), never executed:
```csharp
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var reader = fx.NewReader();
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
}
```
2. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
3. **Impl** — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
4. Commit: `git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"`
---
## Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
**Classification:** high-risk (frozen-peer resilience — the single highest-value integration test)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml` (a dedicated disposable `mssql` stack)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs`
> **CRITICAL:** this gate `docker pause`s a **dedicated** `mcr.microsoft.com/mssql/server` container it owns — **NEVER** the shared central SQL Server on `10.100.0.35,14330`, which hosts `ConfigDb` for the whole rig (design §9). The compose stack is deployed via `lmxopcua-fix sync`; `lmxopcua-fix` applies the `project=lmxopcua` label host-side.
**TDD:**
1. **Failing/skipping test** — mirror the R2-01 S7 blackhole (`S7_1500ConnectTimeoutOutageTests`): start reading against the dedicated mssql, then `docker pause` it mid-poll; assert the next read surfaces `BadTimeout` within `operationTimeout` + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; `docker unpause` recovers. Env-gated (`SQL_BLACKHOLE_ENDPOINT`) — offline skip:
```csharp
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
}
```
2. Run offline — expect SKIP.
3. **Impl** — dedicated `docker-compose.yml` (`mssql` service, own container name, `restart: "no"`), `seed.sql` (the two sample tables), and the test that shells `docker pause`/`docker unpause` against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): `CommandTimeout` (server-side backstop) + `ExecuteReaderAsync(linkedCt)` bounded by `operationTimeout` (client-side real cancellation) both fire.
4. Commit: `git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"`
---
## Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17, Task 18
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (add `[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (add `[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()`)
- Test: `tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs` (locate the existing AdminUI tag-editor test project; mirror `ModbusTagConfigModel` tests)
**TDD:**
1. **Failing test**`FromJson``ToJson` round-trips the model, preserves unknown keys, writes enums (`model`/`type`) as NAME strings not numbers; `Validate()` rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
```csharp
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
var json = m.ToJson();
json.ShouldContain("\"KeyValue\"");
json.ShouldContain("\"Float64\"");
json.ShouldContain("customX"); // unknown key survives load→save
json.ShouldNotContain("\"model\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlTagConfigModel` (thin, over `TagConfigJson.ParseOrNew`, preserving the `_bag`): `Model`, `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn`, `ColumnName` + `RowSelector`, `Type` (`DriverDataType?`), `FromJson`/`ToJson` (enums as name strings via `TagConfigJson.Set`) `/Validate` (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in `TagConfigEditorMap` + `TagConfigValidator`. **Enum-serialization trap (systemic):** `model`/`type` MUST round-trip as strings on both sides — the editor `ToJson` and the factory `SqlEquipmentTagParser` both use string enums; add the assertion above.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"`
---
## Task 20: `SqlTagConfigEditor.razor` (thin shell over the model)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor`
**Steps (Razor — live-verified in Task 21):**
1. Copy the Modbus editor template; bind over `SqlTagConfigModel`: a `Model` dropdown (KeyValue/WideRow) that shows the matching field group, `Table`, the key/value/timestamp fields (KeyValue) OR `ColumnName` + row-selector fields (WideRow), and a `Type` override dropdown. `@bind` string component params need the `@` prefix (the Global-UNS gotcha).
2. `dotnet build` — expect PASS.
3. Commit: `git commit -m "feat(sql): SqlTagConfigEditor razor shell"`
---
## Task 21: Live `/run` verification on docker-dev (picker + editor + deploy + read)
**Classification:** trivial (procedure — no product code; may surface fix-forward tasks)
**Estimated implement time:** ~5 min (verification, excluding any bugs found)
**Parallelizable with:** none (last)
**Files:** none (verification only; any defect becomes a new fix-forward task)
**Procedure (docker-dev rig, AdminUI auto-authenticated at `http://localhost:9200` — login disabled, so drive it yourself, don't defer to the user):**
1. Set `Sql__ConnectionStrings__DevSql` on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (`:9200` round-robins).
2. In `/raw`, add a `Sql` driver + device (paste a literal connection string or use the ref); Test-Connect → green (`SELECT 1`).
3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
4. Reference the raw tags into an equipment on `/uns`; deploy via `POST :9200/api/deployments` (X-Api-Key) or the deploy button; confirm the deployment seals green.
5. Read via Client.CLI (`read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"`) — confirm the live SQL value + quality + source timestamp.
6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
---
## Deferred / out of scope (pointers to the design, NOT executable tasks here)
These are recorded so nobody re-derives them; each is a later phase in `docs/plans/2026-07-15-sql-poll-driver-design.md`. The `ISqlDialect` seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
- **Named-query model** (design §5.4, §3.6(c) / design-P3) — the arbitrary-`SELECT` escape hatch (`queries: {...}` + a tag referencing a query by name + projection). Not built in v1; the `SqlTagModel.Query` enum member exists but the reader/planner path is deferred.
- **PostgreSQL + ODBC dialects** (design §2.2, §8.5 / design-P3) — `PostgresDialect` (`Npgsql`) + `OdbcDialect` (`System.Data.Odbc`), each a NEW gated `PackageVersion`. v1 constructs only `SqlServerDialect`; the other `SqlProvider` members throw "provider not available in this build."
- **MySQL/MariaDB + native Oracle** (design §10 / design-P5) — `MySqlConnector` + an `ALL_TAB_COLUMNS` Oracle dialect. Demand-driven.
- **Write mode (`IWritable`)** (design §3.4 / design-P4) — opt-in, triple-gated (`WriteOperate` role + driver `allowWrites` master switch + `TagConfig.writable`), parameterized UPSERT/StoredProc, `WriteIdempotent` retry policy. v1 is read-only; `SecurityClass=ViewOnly` everywhere and `allowWrites` defaults `false`.
- **Split-role env-parity operational note** (design §4.4) — admin nodes must carry the same `Sql__ConnectionStrings__<ref>` env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.
@@ -0,0 +1,28 @@
{
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
],
"lastUpdated": "2026-07-24"
}
@@ -79,4 +79,46 @@ public sealed class AkkaClusterOptions
/// </para>
/// </remarks>
public string SplitBrainResolverStrategy { get; set; } = "auto-down";
/// <summary>
/// The simultaneous-cold-start split-brain guard (<c>Cluster:BootstrapGuard</c>). Default OFF — a
/// dark switch, so existing deployments and tests keep Akka's config-driven self-first auto-join
/// unchanged. See <see cref="ClusterBootstrapGuard"/> for the decision logic and
/// <c>ClusterBootstrapCoordinator</c> for the runtime.
/// </summary>
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
}
/// <summary>
/// Configuration for the simultaneous-cold-start split-brain guard. When
/// <see cref="Enabled"/>, the node does NOT auto-join from its config seeds; a coordinator picks the
/// join order (founder self-first / joiner peer-first) after a reachability probe. See
/// <see cref="ClusterBootstrapGuard"/>.
/// </summary>
public sealed class ClusterBootstrapGuardOptions
{
/// <summary>
/// Gets or sets whether the guard is active. Default <see langword="false"/> — Akka auto-joins from
/// the config seed list exactly as before. Only meaningful on a node that is one of its own two
/// pair seeds; inert everywhere else.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets how long the higher-address node probes its partner's Akka endpoint before
/// concluding the partner is dead and forming alone. Must comfortably exceed the partner's
/// worst-case process-start-to-Akka-bind time so a slow-but-alive partner is never mistaken for a
/// dead one (which would re-open the split). Default 25 s.
/// </summary>
public int PartnerProbeSeconds { get; set; } = 25;
/// <summary>
/// Gets or sets the interval between partner reachability probes, in milliseconds. Default 500 ms.
/// </summary>
public int PartnerProbeIntervalMs { get; set; } = 500;
/// <summary>
/// Gets or sets the per-probe TCP connect timeout, in milliseconds. Default 1000 ms.
/// </summary>
public int ProbeConnectTimeoutMs { get; set; } = 1000;
}
@@ -41,6 +41,8 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
{
ArgumentNullException.ThrowIfNull(options);
ValidateBootstrapGuard(builder, options.BootstrapGuard);
var seeds = options.SeedNodes ?? Array.Empty<string>();
if (seeds.Length == 0)
{
@@ -68,6 +70,25 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
+ "the entries — see docs/Redundancy.md → 'Bootstrap: self-first seed ordering'.");
}
/// <summary>
/// When the bootstrap guard is enabled, its timing knobs must be positive. A zero/negative
/// <c>PartnerProbeSeconds</c> in particular silently degrades the guard to "never wait, always
/// conclude the partner is dead" — it would form alone immediately and re-open the very split it
/// exists to close. Fail-fast at boot rather than producing silence (the same rationale every
/// sibling options validator in this project cites).
/// </summary>
private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions guard)
{
if (guard is null || !guard.Enabled) return;
if (guard.PartnerProbeSeconds <= 0)
builder.Add($"Cluster:BootstrapGuard:PartnerProbeSeconds must be > 0 when the guard is enabled (was {guard.PartnerProbeSeconds}).");
if (guard.PartnerProbeIntervalMs <= 0)
builder.Add($"Cluster:BootstrapGuard:PartnerProbeIntervalMs must be > 0 when the guard is enabled (was {guard.PartnerProbeIntervalMs}).");
if (guard.ProbeConnectTimeoutMs <= 0)
builder.Add($"Cluster:BootstrapGuard:ProbeConnectTimeoutMs must be > 0 when the guard is enabled (was {guard.ProbeConnectTimeoutMs}).");
}
/// <summary>
/// True when <paramref name="seed"/> addresses this node itself — host AND port.
/// </summary>
@@ -0,0 +1,201 @@
using System.Diagnostics;
using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Runs the simultaneous-cold-start split-brain guard: when <c>Cluster:BootstrapGuard:Enabled</c>, the
/// node starts with NO config seed nodes (see <c>BuildClusterOptions</c>), and this coordinator picks
/// the join order and issues the single <see cref="Akka.Cluster.Cluster.JoinSeedNodes"/> once the
/// ActorSystem is up. See <see cref="ClusterBootstrapGuard"/> for the decision logic and the
/// split-brain rationale.
/// </summary>
/// <remarks>
/// <para>
/// The founder (lower canonical address) joins its self-first order immediately. The higher
/// node probes its partner's Akka endpoint (a plain TCP connect — the partner has bound its
/// port well before it forms) up to <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/>:
/// reachable ⇒ peer-first (join the founder); unreachable ⇒ self-first (partner is dead, form
/// alone). The join runs on a background task so it never blocks host startup, and it is issued
/// exactly once — the coordinator never re-forms a node mid-handshake, the failure mode of the
/// retired <c>SelfFormAfter</c> watchdog.
/// </para>
/// </remarks>
public sealed class ClusterBootstrapCoordinator : IHostedService
{
private readonly Func<ActorSystem> _system;
private readonly AkkaClusterOptions _options;
private readonly ILogger<ClusterBootstrapCoordinator> _log;
private readonly CancellationTokenSource _cts = new();
private Task? _joinTask;
/// <summary>Creates the coordinator.</summary>
/// <param name="system">Lazy accessor for the node's ActorSystem (resolved after Akka's hosted service starts it).</param>
/// <param name="options">The bound cluster options (seed list + guard settings).</param>
/// <param name="log">Logger for the bootstrap decision.</param>
public ClusterBootstrapCoordinator(
Func<ActorSystem> system, IOptions<AkkaClusterOptions> options, ILogger<ClusterBootstrapCoordinator> log)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.BootstrapGuard.Enabled)
return Task.CompletedTask; // dark switch off — Akka auto-joined from config seeds already.
// Fire-and-forget so a higher node with a dead partner (which waits the full probe window)
// never blocks host startup. Exceptions are logged; a failed join leaves the node unjoined,
// which is visible and recoverable, never a silent split.
_joinTask = Task.Run(() => RunAsync(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cts.CancelAsync().ConfigureAwait(false);
if (_joinTask is not null)
{
try { await _joinTask.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected on shutdown */ }
}
}
private async Task RunAsync(CancellationToken ct)
{
try
{
var seeds = _options.SeedNodes ?? Array.Empty<string>();
var role = ClusterBootstrapGuard.Analyze(seeds, _options.PublicHostname, _options.Port);
string[] order;
var committedPeerFirst = false;
if (!role.Applies)
{
// Not a pair seed (single-seed legacy site node, self absent, malformed, or 3+ seeds):
// join the configured seeds unchanged — the guard arbitrates only the 2-node pair race.
order = seeds;
_log.LogInformation(
"Bootstrap guard: not a pair seed ({SeedCount} seed(s)); joining configured seeds unchanged.",
seeds.Length);
}
else if (role.IsFounder)
{
order = role.SelfFirstOrder;
_log.LogInformation(
"Bootstrap guard: this node is the preferred founder (lower address); joining self-first, forming immediately if no peer answers.");
}
else
{
var reachable = await ProbePartnerAsync(role.PartnerHost!, role.PartnerPort, ct).ConfigureAwait(false);
order = ClusterBootstrapGuard.HigherNodeOrder(role, reachable);
committedPeerFirst = reachable;
_log.LogInformation(
"Bootstrap guard: this node is the higher address; partner {PartnerHost}:{PartnerPort} was {Reachability} within the probe window; joining {Order}.",
role.PartnerHost, role.PartnerPort, reachable ? "REACHABLE" : "UNREACHABLE",
reachable ? "peer-first (join the founder)" : "self-first (partner down, forming alone)");
}
if (order.Length == 0)
{
_log.LogWarning("Bootstrap guard: no seed nodes to join; node stays unjoined until a seed is configured.");
return;
}
var cluster = Akka.Cluster.Cluster.Get(_system());
var addresses = order.Select(Address.Parse).ToArray();
cluster.JoinSeedNodes(addresses);
// Peer-first is a one-way commitment: JoinSeedNodeProcess never self-forms. If the founder
// died in the probe→join window, this node hangs unjoined forever. We do NOT re-form here
// (the retired SelfFormAfter mid-handshake failure mode) — we make the hang operator-visible
// so a restart, which re-runs the guard against the now-dead founder and self-forms, recovers
// it. Nothing is done on the founder / self-first paths, which always self-form on their own.
if (committedPeerFirst)
await WarnIfNotUpAsync(cluster, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Host shutting down before the join completed — nothing to do.
}
catch (Exception ex)
{
_log.LogError(ex, "Bootstrap guard: failed to issue JoinSeedNodes; node remains unjoined.");
}
}
/// <summary>
/// After committing peer-first, waits a bounded time for this node to reach <c>Up</c> and logs a
/// clear warning if it does not — the founder must have died in the probe→join window, leaving this
/// node hung in <c>JoinSeedNodeProcess</c>. Read-only: it never re-forms or re-joins (that is the
/// retired mid-handshake failure mode); it only surfaces the condition so an operator restarts the node.
/// </summary>
private async Task WarnIfNotUpAsync(Akka.Cluster.Cluster cluster, CancellationToken ct)
{
// Give the join a generous grace: the founder's own self-form (seed-node-timeout) plus this
// node's join round-trip. Two probe windows is comfortably beyond both.
var grace = TimeSpan.FromSeconds(Math.Max(10, _options.BootstrapGuard.PartnerProbeSeconds * 2));
var sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested && sw.Elapsed < grace)
{
if (cluster.SelfMember.Status == MemberStatus.Up) return; // joined — all good
try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
if (!ct.IsCancellationRequested && cluster.SelfMember.Status != MemberStatus.Up)
_log.LogWarning(
"Bootstrap guard: committed peer-first but this node is still {Status} after {Grace}s — its founder likely died in the probe→join window. This node will NOT self-form on its own (peer-first never does). RESTART it to recover: the guard will re-run, find the founder down, and form alone.",
cluster.SelfMember.Status, (int)grace.TotalSeconds);
}
/// <summary>
/// Polls the partner's Akka endpoint with a plain TCP connect until it is reachable or the probe
/// window elapses. Reachable-at-TCP is a sound proxy for "the partner is coming up": a node binds
/// its Akka port near the start of startup, well before it forms or joins a cluster.
/// </summary>
private async Task<bool> ProbePartnerAsync(string host, int port, CancellationToken ct)
{
var window = TimeSpan.FromSeconds(Math.Max(0, _options.BootstrapGuard.PartnerProbeSeconds));
var interval = TimeSpan.FromMilliseconds(Math.Max(50, _options.BootstrapGuard.PartnerProbeIntervalMs));
var sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested)
{
if (await TryConnectAsync(host, port, ct).ConfigureAwait(false)) return true;
if (sw.Elapsed >= window) return false;
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return false; }
}
return false;
}
private async Task<bool> TryConnectAsync(string host, int port, CancellationToken ct)
{
try
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Math.Max(100, _options.BootstrapGuard.ProbeConnectTimeoutMs));
await client.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
return client.Connected;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // host shutdown — propagate
}
catch
{
return false; // connect refused / timed out / DNS not resolvable yet — partner not up
}
}
}
@@ -0,0 +1,153 @@
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Pure decision logic for the simultaneous-cold-start split-brain guard.
/// </summary>
/// <remarks>
/// <para>
/// Both nodes of a 2-node pair are self-first seeds so <b>either</b> can cold-start alone when
/// its partner is dead (see <see cref="AkkaClusterOptions.SeedNodes"/>). The cost is that when
/// BOTH cold-start at the same instant, each runs Akka's <c>FirstSeedNodeProcess</c>, times out
/// waiting for the other, and forms its OWN single-node cluster — a split brain (two Primaries
/// in one pair). Two independent clusters do not auto-merge, so the split persists until an
/// operator intervenes.
/// </para>
/// <para>
/// This guard breaks the symmetry deterministically without giving up cold-start-alone. The
/// node with the lexicographically <b>lower</b> canonical address is the <i>preferred
/// founder</i>: it always uses self-first order and forms immediately. The <b>higher</b> node
/// first probes whether its partner's Akka endpoint is reachable (see
/// <see cref="ClusterBootstrapCoordinator"/>): reachable ⇒ peer-first order so it JOINS the
/// founder rather than racing it; unreachable after the probe window ⇒ the partner is genuinely
/// down, so it falls back to self-first and forms alone. Exactly one node founds when both are
/// present; the lower node always founds when it starts alone.
/// </para>
/// <para>
/// <b>Residual trade-off (accepted).</b> Once the higher node observes the partner reachable it
/// commits to peer-first — Akka's <c>JoinSeedNodeProcess</c> retries <c>InitJoin</c> forever and
/// never self-forms. If the founder dies in the small window between the probe succeeding and
/// the join completing, the higher node hangs unjoined until it is restarted (a restart re-runs
/// the guard, finds the founder unreachable, and self-forms). We deliberately do NOT re-decide
/// mid-handshake — that is exactly the retired <c>SelfFormAfter</c> failure mode. Instead
/// <see cref="ClusterBootstrapCoordinator"/> makes the hang operator-visible with a warning if
/// the node has not come Up within a bounded time after committing peer-first.
/// </para>
/// <para>
/// This decides the join order BEFORE issuing a single <c>JoinSeedNodes</c>, from an explicit
/// reachability signal — unlike the retired <c>SelfFormAfter</c> watchdog, which fired a
/// <c>Join(self)</c> mid-handshake on a bare timeout and could not tell "no seed answered" from
/// "a join is in flight", islanding a node a failover had just bounced.
/// </para>
/// </remarks>
public static class ClusterBootstrapGuard
{
private static readonly Regex SeedPattern =
new(@"^akka(?:\.[a-z0-9]+)?://[^@/]+@(?<host>[^:/]+):(?<port>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>The analyzed bootstrap role of this node within its seed list.</summary>
/// <param name="Applies">True only when the guard engages: exactly two seeds, one of them this node.</param>
/// <param name="IsFounder">True when this node is the preferred founder (lower address) — form immediately, no probe.</param>
/// <param name="PartnerHost">The partner's advertised host (to probe when this node is the higher one); null when not applicable.</param>
/// <param name="PartnerPort">The partner's Akka port.</param>
/// <param name="SelfFirstOrder"><c>[self, partner]</c> — self-first; forms a new cluster when no peer answers.</param>
/// <param name="PeerFirstOrder"><c>[partner, self]</c> — peer-first; joins the partner's cluster, never self-forms while it is up.</param>
public sealed record BootstrapRole(
bool Applies,
bool IsFounder,
string? PartnerHost,
int PartnerPort,
string[] SelfFirstOrder,
string[] PeerFirstOrder);
/// <summary>
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
/// for the Phase-6 pair shape (exactly two seeds, one of them this node); any other shape
/// (single-seed legacy site node, three+ seeds, self absent) returns <see cref="BootstrapRole.Applies"/>
/// = false and the caller joins the configured seeds unchanged.
/// </summary>
/// <param name="seeds">The configured seed URIs (self-first by convention, but order is not relied on here).</param>
/// <param name="selfHost">This node's advertised host (<see cref="AkkaClusterOptions.PublicHostname"/>).</param>
/// <param name="selfPort">This node's Akka port (<see cref="AkkaClusterOptions.Port"/>).</param>
/// <returns>The analyzed role; never null.</returns>
public static BootstrapRole Analyze(IReadOnlyList<string> seeds, string selfHost, int selfPort)
{
ArgumentNullException.ThrowIfNull(seeds);
ArgumentException.ThrowIfNullOrWhiteSpace(selfHost);
var na = new BootstrapRole(false, false, null, 0, Array.Empty<string>(), Array.Empty<string>());
if (seeds.Count != 2) return na;
var selfKey = Key(selfHost, selfPort);
string? self = null, partner = null;
string? partnerHost = null;
var partnerPort = 0;
foreach (var seed in seeds)
{
if (!TryParse(seed, out var host, out var port)) return na;
if (string.Equals(Key(host, port), selfKey, StringComparison.OrdinalIgnoreCase))
self = seed;
else
{
partner = seed;
partnerHost = host;
partnerPort = port;
}
}
// Self must be exactly one of the two, and the other must be a distinct partner.
if (self is null || partner is null || partnerHost is null) return na;
var selfFirst = new[] { self, partner };
var peerFirst = new[] { partner, self };
// Deterministic tie-break: the lower canonical address is the preferred founder. Both nodes
// compute the same comparison (same two seeds), so exactly one is the founder. Case-INSENSITIVE
// to match how self/partner are classified above (and AkkaClusterOptionsValidator.IsSelf):
// container/DNS hostnames are conventionally case-insensitive, and a casing difference between
// the two sides' seed config must NOT make both think they are the founder (which would reopen
// the very split this guard closes).
var isFounder = string.Compare(
Key(selfHost, selfPort),
Key(partnerHost, partnerPort),
StringComparison.OrdinalIgnoreCase) < 0;
return new BootstrapRole(true, isFounder, partnerHost, partnerPort, selfFirst, peerFirst);
}
/// <summary>
/// The order the higher (non-founder) node uses once it has learned whether its partner is
/// reachable: peer-first when the founder is up (join it), self-first when the founder is absent
/// (form alone — cold-start-alone preserved).
/// </summary>
/// <param name="role">The analyzed role (must be applicable and NOT the founder).</param>
/// <param name="partnerReachable">Whether the partner's Akka endpoint became reachable within the probe window.</param>
/// <returns>The seed order to pass to <c>Cluster.JoinSeedNodes</c>.</returns>
public static string[] HigherNodeOrder(BootstrapRole role, bool partnerReachable)
{
ArgumentNullException.ThrowIfNull(role);
return partnerReachable ? role.PeerFirstOrder : role.SelfFirstOrder;
}
/// <summary>Parses <c>akka.tcp://system@host:port[/...]</c> into host + port.</summary>
/// <param name="seed">The seed URI.</param>
/// <param name="host">The parsed advertised host.</param>
/// <param name="port">The parsed Akka port.</param>
/// <returns>True when the seed matched the expected shape.</returns>
public static bool TryParse(string? seed, out string host, out int port)
{
host = string.Empty;
port = 0;
if (string.IsNullOrWhiteSpace(seed)) return false;
var m = SeedPattern.Match(seed.Trim());
if (!m.Success) return false;
if (!int.TryParse(m.Groups["port"].Value, out port)) return false;
host = m.Groups["host"].Value;
return true;
}
private static string Key(string host, int port) => $"{host}:{port}";
}
@@ -241,7 +241,13 @@ public static class ServiceCollectionExtensions
return new ClusterOptions
{
SeedNodes = options.SeedNodes,
// When the bootstrap guard is on, DO NOT hand Akka the seed list: Akka.Cluster.Hosting
// auto-joins from ClusterOptions.SeedNodes the instant the ActorSystem starts, which is
// exactly the self-first race the guard exists to arbitrate. Left empty, the node starts
// unjoined and ClusterBootstrapCoordinator picks the order (founder self-first / joiner
// peer-first, after a reachability probe) and issues the single Cluster.JoinSeedNodes.
// The config seed list is still read — by the coordinator, off AkkaClusterOptions.SeedNodes.
SeedNodes = options.BootstrapGuard.Enabled ? Array.Empty<string>() : options.SeedNodes,
Roles = options.Roles,
SplitBrainResolver = IsKeepOldest(options) ? new KeepOldestOption { DownIfAlone = true } : null,
};
@@ -41,9 +41,100 @@ public static class DraftValidator
ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
return errors;
}
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// </summary>
/// <remarks>
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
/// Discarding a secret on read is not the same as refusing to store it.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
/// device blob lands in exactly the same place.</para>
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
/// log and the audit trail; repeating the offending string there would leak the very credential the
/// rule is refusing to store.</para>
/// </remarks>
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
{
const string ForbiddenKey = "connectionString";
var sqlInstanceIds = draft.DriverInstances
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
.Select(d => d.DriverInstanceId)
.ToHashSet(StringComparer.Ordinal);
if (sqlInstanceIds.Count == 0) return;
foreach (var d in draft.DriverInstances)
{
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
"from the environment / secret store at Initialize; a literal connection string here would be " +
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
"Remove the key and set 'connectionStringRef'.",
d.DriverInstanceId));
}
foreach (var dev in draft.Devices)
{
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
dev.DeviceId));
}
}
/// <summary>
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
/// and shaping the config JSON is another rule's job.
/// </summary>
/// <param name="json">The config blob to inspect.</param>
/// <param name="key">The property name to look for.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
private static bool HasTopLevelKey(string? json, string key)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
foreach (var property in doc.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -53,6 +53,9 @@ public static class DriverTypeNames
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
public const string Calculation = "Calculation";
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
public const string Sql = "Sql";
/// <summary>
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored <c>DriverType</c>).
@@ -68,5 +71,6 @@ public static class DriverTypeNames
OpcUaClient,
Galaxy,
Calculation,
Sql,
];
}
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
/// <summary>
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
/// severity, the rendered message template and the last-observed input quality, i.e. everything
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
/// the next transition.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
/// transition to report), so the transition stream cannot carry the current state — that is
/// exactly why the caller needs this. Routing these through the emission path instead would
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
/// intentionally one the emission machinery cannot consume.
/// </para>
/// <para>
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
/// read publishes its own transition through the normal path.
/// </para>
/// </remarks>
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
{
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
foreach (var (alarmId, state) in _alarms)
{
projections.Add(new ScriptedAlarmProjection(
AlarmId: alarmId,
Severity: state.Definition.Severity,
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
Condition: state.Condition,
WorstInputStatusCode: LastWorstStatus(alarmId)));
}
return projections;
}
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the acknowledgment.</param>
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u);
/// <summary>
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
/// for a future refactor to mistake for a transition.
/// </summary>
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
/// <param name="Severity">The definition's severity bucket.</param>
/// <param name="Message">The message template resolved against the current value cache.</param>
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
public sealed record ScriptedAlarmProjection(
string AlarmId,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
uint WorstInputStatusCode);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge.
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public static class AbCipStatusMapper
{
public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u;
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
public static class AbLegacyStatusMapper
{
public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u;
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
{
rejectedTcs.TrySetResult(new DataValueSnapshot(
Value: null,
StatusCode: 0x80000000u, // Bad
StatusCode: StatusCodeMap.Bad,
SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow));
}
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
{
tcs.TrySetResult(new DataValueSnapshot(
Value: null,
StatusCode: 0x800B0000u, // BadTimeout
StatusCode: StatusCodeMap.BadTimeout,
SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow));
}
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
{
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
//
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
// originally written as though the DA byte could be shifted into the substatus position, which
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
// against the pinned SDK's Opc.Ua.StatusCodes.
public const uint Good = 0x00000000u;
public const uint GoodLocalOverride = 0x00D80000u;
public const uint GoodLocalOverride = 0x00960000u;
public const uint Uncertain = 0x40000000u;
public const uint UncertainLastUsableValue = 0x40A40000u;
public const uint UncertainSensorNotAccurate = 0x408D0000u;
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
public const uint UncertainSubNormal = 0x408F0000u;
public const uint UncertainLastUsableValue = 0x40900000u;
public const uint UncertainSensorNotAccurate = 0x40930000u;
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
public const uint UncertainSubNormal = 0x40950000u;
public const uint Bad = 0x80000000u;
public const uint BadConfigurationError = 0x80890000u;
public const uint BadNotConnected = 0x808A0000u;
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u;
/// <summary>
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
/// </summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
public static uint Map(byte q) => q switch
{
// Good family (192+)
192 => 0x00000000u, // Good
216 => 0x00D80000u, // Good_LocalOverride
192 => HistorianStatusCodes.Good,
216 => HistorianStatusCodes.GoodLocalOverride,
// Uncertain family (64-191)
64 => 0x40000000u, // Uncertain
68 => 0x40900000u, // Uncertain_LastUsableValue
80 => 0x40930000u, // Uncertain_SensorNotAccurate
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
88 => 0x40950000u, // Uncertain_SubNormal
64 => HistorianStatusCodes.Uncertain,
68 => HistorianStatusCodes.UncertainLastUsableValue,
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
88 => HistorianStatusCodes.UncertainSubNormal,
// Bad family (0-63)
0 => 0x80000000u, // Bad
4 => 0x80890000u, // Bad_ConfigurationError
8 => 0x808A0000u, // Bad_NotConnected
12 => 0x808B0000u, // Bad_DeviceFailure
16 => 0x808C0000u, // Bad_SensorFailure
20 => 0x80050000u, // Bad_CommunicationError
24 => 0x808D0000u, // Bad_OutOfService
32 => 0x80320000u, // Bad_WaitingForInitialData
0 => HistorianStatusCodes.Bad,
4 => HistorianStatusCodes.BadConfigurationError,
8 => HistorianStatusCodes.BadNotConnected,
12 => HistorianStatusCodes.BadDeviceFailure,
16 => HistorianStatusCodes.BadSensorFailure,
20 => HistorianStatusCodes.BadCommunicationError,
24 => HistorianStatusCodes.BadOutOfService,
32 => HistorianStatusCodes.BadWaitingForInitialData,
// Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u,
_ when q >= 64 => 0x40000000u,
_ => 0x80000000u,
_ when q >= 192 => HistorianStatusCodes.Good,
_ when q >= 64 => HistorianStatusCodes.Uncertain,
_ => HistorianStatusCodes.Bad,
};
}
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <summary>
/// The OPC UA status codes this driver publishes, as named constants.
/// </summary>
/// <remarks>
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
/// A literal written at a call site is invisible to that guard, which is exactly how
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
/// </remarks>
internal static class HistorianStatusCodes
{
// ---- Good family ----
/// <summary>The value is good; no qualification.</summary>
public const uint Good = 0x00000000u;
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
public const uint GoodLocalOverride = 0x00960000u;
// ---- Uncertain family ----
/// <summary>The value is uncertain; no specific reason.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
public const uint UncertainLastUsableValue = 0x40900000u;
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
public const uint UncertainSensorNotAccurate = 0x40930000u;
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
public const uint UncertainSubNormal = 0x40950000u;
// ---- Bad family ----
/// <summary>The value is bad; no specific reason.</summary>
public const uint Bad = 0x80000000u;
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>The source is not connected (OPC DA quality 8).</summary>
public const uint BadNotConnected = 0x808A0000u;
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
public const uint BadSensorFailure = 0x808C0000u;
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The source is out of service (OPC DA quality 24).</summary>
public const uint BadOutOfService = 0x808D0000u;
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The historian returned no data for the requested tag/interval. Distinct from a transport
/// failure: the query succeeded and the answer was empty.
/// </summary>
public const uint BadNoData = 0x809B0000u;
}
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </summary>
internal static class SampleMapper
{
private const uint StatusGood = 0x00000000u;
private const uint StatusBadNoData = 0x800E0000u;
private const uint StatusGood = HistorianStatusCodes.Good;
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
private const byte GoodQualityFloor = 192;
@@ -0,0 +1,201 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
public enum SqlBrowseNodeKind
{
/// <summary>A schema — the browse root level.</summary>
Schema,
/// <summary>A table or view inside a schema.</summary>
Table,
/// <summary>A column of a table or view — the terminal (leaf) level.</summary>
Column,
}
/// <summary>A decoded browse <c>NodeId</c>.</summary>
/// <param name="Kind">Which catalog level this reference addresses.</param>
/// <param name="Schema">The schema name. Always present.</param>
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
public readonly record struct SqlBrowseNodeRef(
SqlBrowseNodeKind Kind,
string Schema,
string? Table,
string? Column);
/// <summary>
/// Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
/// on expand / attribute fetch / column commit.
/// <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
/// <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
/// inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
/// both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
/// <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
/// + table <c>b</c>, and a table named <c>x|y</c> loses its second half entirely. That is not a cosmetic
/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
/// </para>
/// <para><b>The encoding.</b> <c>&lt;kind&gt;:&lt;part&gt;[|&lt;part&gt;…]</c>, where <c>kind</c> is one
/// of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
/// can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
/// <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
/// readable (<c>column:dbo|TagValues|num_value</c>). The kind prefix carries the arity, so decoding never
/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is
/// rejected rather than truncated or padded.</para>
/// <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
/// column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. Encode and decode
/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
/// exists to prevent.</para>
/// </summary>
public static class SqlBrowseNodeId
{
private const char Separator = '|';
private const char Escape = '\\';
private const char KindTerminator = ':';
private const string SchemaKind = "schema";
private const string TableKind = "table";
private const string ColumnKind = "column";
/// <summary>Encodes a schema-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
public static string ForSchema(string schema) => Encode(SchemaKind, schema);
/// <summary>Encodes a table-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
/// <summary>Encodes a column-level (leaf) NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <param name="column">The column name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
public static string ForColumn(string schema, string table, string column) =>
Encode(ColumnKind, schema, table, column);
/// <summary>Attempts to decode a NodeId.</summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
/// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
{
reference = default;
if (string.IsNullOrWhiteSpace(nodeId)) return false;
var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
if (terminator <= 0) return false;
var kind = nodeId[..terminator];
if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
if (parts.Exists(string.IsNullOrWhiteSpace)) return false;
switch (kind)
{
case SchemaKind when parts.Count == 1:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
return true;
case TableKind when parts.Count == 2:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
return true;
case ColumnKind when parts.Count == 3:
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
return true;
default:
return false;
}
}
/// <summary>
/// Decodes a NodeId, throwing when it is malformed.
/// <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
/// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
/// </summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <returns>The decoded reference.</returns>
/// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
public static SqlBrowseNodeRef Parse(string? nodeId)
{
if (TryParse(nodeId, out var reference)) return reference;
throw new ArgumentException(
"Not a SQL schema-browse node. Expected 'schema:<schema>', 'table:<schema>|<table>' or " +
"'column:<schema>|<table>|<column>'. Re-open the browser and expand from the root.",
nameof(nodeId));
}
private static string Encode(string kind, params string[] parts)
{
var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
builder.Append(kind).Append(KindTerminator);
for (var i = 0; i < parts.Length; i++)
{
if (string.IsNullOrWhiteSpace(parts[i]))
{
throw new ArgumentException(
"A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
}
if (i > 0) builder.Append(Separator);
AppendEscaped(builder, parts[i]);
}
return builder.ToString();
}
private static void AppendEscaped(StringBuilder builder, string part)
{
foreach (var ch in part)
{
if (ch is Separator or Escape) builder.Append(Escape);
builder.Append(ch);
}
}
/// <summary>
/// Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
/// <c>false</c> on a dangling trailing escape, which cannot be produced by
/// <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
/// </summary>
private static bool TrySplit(string payload, out List<string> parts)
{
parts = [];
var current = new StringBuilder(payload.Length);
var escaped = false;
foreach (var ch in payload)
{
if (escaped)
{
current.Append(ch);
escaped = false;
continue;
}
switch (ch)
{
case Escape:
escaped = true;
break;
case Separator:
parts.Add(current.ToString());
current.Clear();
break;
default:
current.Append(ch);
break;
}
}
if (escaped) return false;
parts.Add(current.ToString());
return true;
}
}
@@ -0,0 +1,304 @@
using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
/// column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
/// two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
/// the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
/// never needs an identifier in text.</para>
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
/// <see cref="DisposeAsync"/>.</b> It does not open one, and it never re-opens a closed one. The handoff
/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
/// if the session did not close it, nothing would, and every reaped picker would leak a pooled
/// connection.</para>
/// <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
/// 20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); this session's job is simply to
/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
/// independent deadline here would only produce two competing, differently-worded failures.</para>
/// </summary>
internal sealed class SqlBrowseSession : IBrowseSession
{
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
private const string TableTypeColumn = "TABLE_TYPE";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Column alias carrying the SQL type family name.</summary>
private const string DataTypeColumn = "DATA_TYPE";
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
/// <summary>
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
/// </summary>
private const string ReadOnlySecurityClass = "ViewOnly";
private readonly DbConnection _connection;
private readonly ISqlDialect _dialect;
/// <summary>
/// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
/// the AdminUI tree happily fires several expands at once when an operator clicks quickly.
/// </summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
/// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
/// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
}
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <inheritdoc />
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <inheritdoc />
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
var schemas = await QueryAsync(
_dialect.ListSchemasSql,
static _ => { },
static reader => ReadString(reader, SchemaColumn),
cancellationToken).ConfigureAwait(false);
var nodes = new List<BrowseNode>(schemas.Count);
foreach (var schema in schemas)
{
if (string.IsNullOrWhiteSpace(schema)) continue;
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForSchema(schema),
DisplayName: schema,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: true));
}
return nodes;
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var reference = SqlBrowseNodeId.Parse(nodeId);
switch (reference.Kind)
{
case SqlBrowseNodeKind.Schema:
return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
case SqlBrowseNodeKind.Table:
return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
.ConfigureAwait(false);
default:
// A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
// it has re-read the node's Kind.
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<BrowseNode>();
}
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(
string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var reference = SqlBrowseNodeId.Parse(nodeId);
if (reference.Kind != SqlBrowseNodeKind.Column)
{
// Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
// uniform and returns empty for every node.
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<AttributeInfo>();
}
var columns = await ReadColumnsAsync(
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
// that legitimately carries both "Value" and "value".
foreach (var column in columns)
{
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
return
[
new AttributeInfo(
Name: column.Name,
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
IsArray: false,
SecurityClass: ReadOnlySecurityClass),
];
}
// The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
// the honest answer; the picker simply has nothing to prefill.
return Array.Empty<AttributeInfo>();
}
/// <summary>
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await _connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the connection may already be broken or closed. */ }
try { _gate.Dispose(); }
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
}
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
{
var rows = await QueryAsync(
_dialect.ListTablesSql,
command => Bind(command, "@schema", schema),
static reader => (
Name: ReadString(reader, TableNameColumn),
Type: ReadOptionalString(reader, TableTypeColumn)),
ct).ConfigureAwait(false);
var nodes = new List<BrowseNode>(rows.Count);
foreach (var (name, type) in rows)
{
if (string.IsNullOrWhiteSpace(name)) continue;
var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForTable(schema, name),
// The label is decorated, never the NodeId — a view and a table of the same name in the same
// schema cannot exist, so the suffix is presentation only.
DisplayName: isView ? $"{name} (view)" : name,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: true));
}
return nodes;
}
private async Task<IReadOnlyList<BrowseNode>> ExpandTableAsync(
string schema, string table, CancellationToken ct)
{
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
var nodes = new List<BrowseNode>(columns.Count);
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
nodes.Add(new BrowseNode(
NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
DisplayName: column.Name,
Kind: BrowseNodeKind.Leaf,
HasChildrenHint: false));
}
return nodes;
}
/// <summary>
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
/// relation, and neither is a reason to fail an operator's click.
/// </summary>
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
string schema, string table, CancellationToken ct) =>
QueryAsync(
_dialect.ListColumnsSql,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
ct);
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
/// </summary>
private async Task<List<T>> QueryAsync<T>(
string sql,
Action<DbCommand> bind,
Func<DbDataReader, T> project,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var results = new List<T>();
await using var command = _connection.CreateCommand();
command.CommandText = sql;
bind(command);
await using var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(project(reader));
LastUsedUtc = DateTime.UtcNow;
return results;
}
finally
{
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
// ignore and must not mask the real result.
try { _gate.Release(); }
catch (ObjectDisposedException) { }
}
}
/// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
private static string ReadString(DbDataReader reader, string columnName) =>
ReadOptionalString(reader, columnName) ?? string.Empty;
private static string? ReadOptionalString(DbDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
}
}
@@ -0,0 +1,401 @@
using System.Data.Common;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>
/// Opens a <b>transient</b> database connection from form-supplied JSON for the AdminUI schema picker,
/// and hands it to a <see cref="SqlBrowseSession"/>. The AdminUI's <c>BrowseSessionRegistry</c> (its idle
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
/// registry of its own.
/// <para><b>Two ways to name the database, and only one of them is persisted.</b>
/// <c>connectionStringRef</c> is the deployed form: a name resolved <em>in the AdminUI process</em> from
/// the environment as <c>Sql__ConnectionStrings__&lt;ref&gt;</c> (design §4.4), so a deployed artifact
/// never carries credentials. <c>connectionString</c> is an ad-hoc, <b>session-only</b> literal an
/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
/// anywhere.</para>
/// <para><b>Precedence: a pasted literal wins over a ref</b>, and the ref is then not even read. The
/// literal cannot arrive from a persisted blob — <see cref="SqlDriverConfigDto"/> has no such property, so
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
/// the override is visible rather than silent.</para>
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
/// own errors, every failure on the open path goes through <see cref="Sanitize"/> before it leaves this
/// class.</para>
/// </summary>
public sealed class SqlDriverBrowser : IDriverBrowser
{
/// <summary>
/// Prefix of the environment variable a <c>connectionStringRef</c> resolves through — the
/// double-underscore form .NET configuration uses for the <c>Sql:ConnectionStrings:&lt;ref&gt;</c>
/// key path.
/// </summary>
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
/// <summary>
/// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
/// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
/// picker entirely.
/// </summary>
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
/// <summary>
/// Connection-string keys whose <em>values</em> are redacted out of any provider error text. The
/// whole connection string is redacted unconditionally; these are the parts that could survive as a
/// fragment. Server / database names are deliberately <b>not</b> redacted — "server X was not found"
/// is the diagnostic the operator needs, and a hostname is not a credential.
/// </summary>
private static readonly string[] CredentialKeys =
[
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
];
/// <summary>
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
/// knobs are authored as their <em>names</em>, so the browser parses a given <c>DriverConfig</c> blob
/// exactly as the runtime factory does. <see cref="JsonStringEnumConverter"/> also accepts ordinals,
/// so a numeric config still binds.
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly Func<SqlProvider, ISqlDialect?> _dialectSelector;
private readonly Func<string, string?> _environmentReader;
private readonly ILogger<SqlDriverBrowser> _logger;
/// <summary>Creates a browser. Every dependency has a production default, so DI may construct it bare.</summary>
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
/// provider gets added without touching this class.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
/// Injectable because environment variables are process-global, and a test that sets one races every
/// other test in the assembly.
/// </param>
/// <param name="logger">Optional; defaults to <see cref="NullLogger{T}"/>.</param>
public SqlDriverBrowser(
Func<SqlProvider, ISqlDialect?>? dialectSelector = null,
Func<string, string?>? environmentReader = null,
ILogger<SqlDriverBrowser>? logger = null)
{
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
_logger = logger ?? NullLogger<SqlDriverBrowser>.Instance;
}
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name, e.g. <c>Sql__ConnectionStrings__MesStaging</c>.</returns>
public static string EnvironmentVariableFor(string connectionStringRef) =>
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
/// the caller reports it as unavailable rather than crashing on a null dialect.
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
/// a <c>connectionStringRef</c> nor a literal, resolves a ref that is not set in the environment, or
/// the connection could not be opened.
/// </exception>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(configJson))
{
throw new InvalidOperationException(
"The Sql browser requires a driver configuration; none was supplied.");
}
var literal = ReadPastedConnectionString(configJson);
var config = Deserialize(configJson, literal);
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
// named, and answering it before touching the environment keeps the two failures from masking.
var dialect = _dialectSelector(config.Provider)
?? throw new InvalidOperationException(
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ $"'{SqlProvider.SqlServer}' only.");
var reference = config.ConnectionStringRef;
var connectionString = ResolveConnectionString(literal, reference);
_logger.LogInformation(
"AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
config.Provider,
DescribeSource(literal, reference));
return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Opens one transient connection and hands ownership to the session. <b>The session closes the
/// connection on its own disposal</b>, so the only disposal this method owns is the failure path —
/// disposing on success would double-close and leave the picker with a dead session.
/// </summary>
private static async Task<IBrowseSession> OpenSessionAsync(
ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException(
$"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
try
{
connection.ConnectionString = connectionString;
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(ConnectBudget);
await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
return new SqlBrowseSession(connection, dialect);
}
catch (Exception ex)
{
try { await connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the original failure is the useful one. */ }
if (ex is OperationCanceledException) throw;
throw Sanitize(ex, connectionString);
}
}
/// <summary>
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
/// form field), never as an empty connection string.
/// </summary>
private string ResolveConnectionString(string? literal, string? reference)
{
if (!string.IsNullOrWhiteSpace(literal))
{
if (!string.IsNullOrWhiteSpace(reference))
{
_logger.LogWarning(
"Sql browse: a pasted connection string was supplied alongside connectionStringRef "
+ "'{Ref}'. The pasted value wins for this session only and is not persisted.",
reference);
}
return literal;
}
if (string.IsNullOrWhiteSpace(reference))
{
throw new InvalidOperationException(
"The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
+ $"from the environment as '{ConnectionStringEnvironmentPrefix}<ref>', or paste a "
+ "connection string into 'connectionString' for an ad-hoc browse.");
}
var variable = EnvironmentVariableFor(reference);
var resolved = _environmentReader(variable);
if (string.IsNullOrWhiteSpace(resolved))
{
// Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
// move is to set this one name on the AdminUI host.
throw new InvalidOperationException(
$"connectionStringRef '{reference}' does not resolve: environment variable "
+ $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
+ "connection strings in its own process), or paste a connection string for an ad-hoc "
+ "browse.");
}
return resolved;
}
/// <summary>How the connection string was obtained, for the log. Carries no connection text.</summary>
private static string DescribeSource(string? literal, string? reference) =>
!string.IsNullOrWhiteSpace(literal)
? "a pasted literal (session-only, not persisted)"
: $"connectionStringRef '{reference}'";
/// <summary>
/// Pulls the ad-hoc <c>connectionString</c> literal out of the raw JSON. It is read here rather than
/// off the DTO because <see cref="SqlDriverConfigDto"/> deliberately has no such property — the
/// persisted contract must not be able to carry a credential — and the DTO's
/// <see cref="JsonUnmappedMemberHandling.Skip"/> would silently drop it.
/// </summary>
/// <returns>The literal, or <see langword="null"/> when absent, blank, or not a JSON string.</returns>
private static string? ReadPastedConnectionString(string configJson)
{
using var document = ParseDocument(configJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration must be a JSON object.");
}
foreach (var property in document.RootElement.EnumerateObject())
{
if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
continue;
if (property.Value.ValueKind != JsonValueKind.String) return null;
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
/// <summary>
/// Parses the raw configuration. The <see cref="JsonException"/> is <b>not</b> attached or echoed:
/// at this point nothing has been extracted, so there is no known secret to redact against, and the
/// malformed text may itself be a pasted connection string.
/// </summary>
private static JsonDocument ParseDocument(string configJson)
{
try
{
return JsonDocument.Parse(configJson);
}
catch (JsonException)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration is not valid JSON. (The parser's message is "
+ "withheld because the malformed text may carry a connection string.)");
}
}
/// <summary>
/// Binds the typed configuration. A bind failure (e.g. an unknown <c>provider</c> name) is reported
/// with the parser's own message redacted against the literal, which is known by now.
/// </summary>
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(configJson, JsonOpts)
?? throw new InvalidOperationException(
"The Sql browser's driver configuration deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
"The Sql browser's driver configuration could not be bound: "
+ Redact(ex.Message, CollectSecrets(literal)));
}
}
/// <summary>
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
/// <para><b>Live-probed, not assumed.</b> <c>Microsoft.Data.SqlClient</c> and
/// <c>Microsoft.Data.Sqlite</c> keep connection-string <em>values</em> out of their
/// <c>SqlException</c>/<c>SqliteException</c> text (a failed connect says "the server was not found",
/// never what it was handed). Their connection-string <b>parser</b> is the exception: it reports an
/// unrecognised keyword by quoting it, and a value carrying an unquoted <c>;</c> — legal inside a
/// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
/// keyword and echoed verbatim. <c>Password=Sup3r;SecretTail</c> yields
/// <c>Keyword not supported: 'secrettail;connect timeout'.</c> — the credential, in the message,
/// lower-cased and therefore invisible to a naive ordinal search for the value.</para>
/// <para>So the parser's message (an <see cref="ArgumentException"/>) is <b>never</b> surfaced. Every
/// other failure is additionally passed through the substring redactor as a backstop, and when that
/// fires anywhere in the exception <em>chain</em> the inner exception is dropped too — keeping it
/// would re-expose through <c>ToString()</c> exactly what the message just removed.</para>
/// </summary>
private static InvalidOperationException Sanitize(Exception ex, string connectionString)
{
if (ex is ArgumentException)
{
return new InvalidOperationException(
"The Sql browser could not open a connection: the connection string is malformed, or "
+ "carries a keyword this provider does not support. (The provider's own message is "
+ "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
+ "or '=' inside a value such as the password.)");
}
var secrets = CollectSecrets(connectionString);
var leaked = ContainsAny(ex.ToString(), secrets);
var reason = Redact(ex.Message, secrets);
var message = leaked
? "The Sql browser could not open a connection: " + reason
+ " (the provider's error echoed the connection string, so it was redacted and the inner "
+ "exception withheld)"
: "The Sql browser could not open a connection: " + reason;
return new InvalidOperationException(message, leaked ? null : ex);
}
/// <summary>
/// The substrings that must never leave this class: the whole connection string, and the values of
/// its credential-bearing keys (which could surface on their own).
/// </summary>
private static List<string> CollectSecrets(string? connectionString)
{
var secrets = new List<string>();
if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
secrets.Add(connectionString);
try
{
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
foreach (var key in CredentialKeys)
{
if (!builder.TryGetValue(key, out var value)) continue;
var text = value?.ToString();
if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
}
}
catch (ArgumentException)
{
// A malformed connection string has no parseable parts; the whole-string entry still stands.
}
// Longest first, so redacting a short credential value cannot chop a longer secret into
// unredactable halves.
secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
return secrets;
}
private static bool ContainsAny(string text, List<string> secrets)
{
foreach (var secret in secrets)
{
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
private static string Redact(string text, List<string> secrets)
{
foreach (var secret in secrets)
{
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
}
return text;
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The <c>DriverConfig</c> blob shape for a <c>Sql</c> driver instance (design §5.1). Deserialised by the
/// driver factory, which applies the documented defaults for every absent (null) field and maps the result
/// onto the driver's typed options.
/// <para><b>No connection string lives here.</b> Only <see cref="ConnectionStringRef"/> — a name resolved at
/// Initialize from the environment / secret store (e.g. <c>Sql__ConnectionStrings__MesStaging</c>) — so a
/// deployed artifact never carries database credentials.</para>
/// <para>Enum-valued fields are enum-typed (not strings) so a <c>JsonStringEnumConverter</c> round-trips
/// their <em>names</em> rather than their ordinals. The converter is applied by the factory's
/// <c>JsonSerializerOptions</c> (Task 9), not by an attribute on this DTO — mirroring the driver
/// enum-serialization trap that bit the AdminUI driver pages.</para>
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
/// Name of the connection string to resolve from the environment / secret store at Initialize —
/// <b>never</b> the connection string itself.
/// </summary>
public string? ConnectionStringRef { get; init; }
/// <summary>Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default.</summary>
public TimeSpan? DefaultPollInterval { get; init; }
/// <summary>
/// Per-query wall-clock deadline enforced client-side (a breach surfaces <c>BadTimeout</c>). Must be
/// greater than <see cref="CommandTimeout"/>, which is only the server-side backstop (design §8.3).
/// </summary>
public TimeSpan? OperationTimeout { get; init; }
/// <summary>ADO.NET <c>CommandTimeout</c> backstop (seconds granularity server-side).</summary>
public TimeSpan? CommandTimeout { get; init; }
/// <summary>Cap on concurrently executing group queries — the connection-pool guard.</summary>
public int? MaxConcurrentGroups { get; init; }
/// <summary>When <see langword="true"/>, a NULL cell publishes Bad rather than the default Uncertain.</summary>
public bool? NullIsBad { get; init; }
/// <summary>
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
/// an operator who believes writes are enabled can.
/// </summary>
public bool? AllowWrites { get; init; }
/// <summary>
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
/// the driver <c>TagConfig</c> blob, the same shape every other driver receives. The factory copies
/// these onto the driver's options and the driver maps each through
/// <see cref="SqlEquipmentTagParser.TryParse"/> at Initialize.
/// <para>A SQL source has no tag-discovery protocol, so this list is the complete set of tags the
/// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will
/// find its tags later.</para>
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
}
@@ -0,0 +1,131 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Pure mapper from an authored raw tag's <c>TagConfig</c> JSON (design §5.2 / §5.3) to a
/// <see cref="SqlTagDefinition"/>, mirroring <c>ModbusTagDefinitionFactory.FromTagConfig</c>. The blob is
/// recognised by a leading <c>{</c> plus a <c>"driver":"Sql"</c> marker or a <c>"model"</c> discriminator;
/// the <c>model</c> and <c>type</c> enums are read with
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so a present-but-invalid (typo'd) value rejects
/// the whole tag — the driver then surfaces <c>BadNodeIdUnknown</c> instead of silently defaulting to a
/// different model and publishing a misleading <c>Good</c> (R2-11).
/// <para><b>No SQL is built here.</b> The parser only captures strings; the reader binds value fields as
/// <c>DbParameter</c>s and validates + quotes identifier fields. Tag input is never concatenated into a
/// command text, so a hostile <c>keyValue</c> is stored — and must be stored — verbatim.</para>
/// </summary>
public static class SqlEquipmentTagParser
{
/// <summary>
/// Maps an authored <c>TagConfig</c> blob to a typed definition keyed by <paramref name="rawPath"/>.
/// Returns <see langword="false"/> — never throws — for anything the driver must not serve: a
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd <c>model</c> or
/// <c>type</c> enum, a missing model-required field, or the P3-deferred
/// <see cref="SqlTagModel.Query"/> model.
/// </summary>
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(reference)) return false;
if (reference.TrimStart().FirstOrDefault() != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// Recognition: either the explicit driver marker or the model discriminator must be present,
// so another driver's TagConfig blob is not mistaken for a Sql one.
var driver = ReadString(root, "driver");
var hasModel = root.TryGetProperty("model", out _);
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
// Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
DriverDataType? declaredType =
root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
? type
: null;
var table = ReadString(root, "table");
if (string.IsNullOrWhiteSpace(table)) return false;
var timestampColumn = ReadString(root, "timestampColumn");
switch (model)
{
case SqlTagModel.KeyValue:
{
var keyColumn = ReadString(root, "keyColumn");
var keyValue = ReadString(root, "keyValue");
var valueColumn = ReadString(root, "valueColumn");
if (string.IsNullOrWhiteSpace(keyColumn)
|| string.IsNullOrWhiteSpace(valueColumn)
|| keyValue is null)
return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
TimestampColumn: timestampColumn, DeclaredType: declaredType);
return true;
}
case SqlTagModel.WideRow:
{
var columnName = ReadString(root, "columnName");
if (string.IsNullOrWhiteSpace(columnName)) return false;
string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
{
selectorColumn = ReadString(sel, "whereColumn");
selectorValue = ReadScalar(sel, "whereValue");
topByTimestamp = ReadString(sel, "topByTimestamp");
}
// A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
TimestampColumn: timestampColumn, ColumnName: columnName,
RowSelectorColumn: hasWherePair ? selectorColumn : null,
RowSelectorValue: hasWherePair ? selectorValue : null,
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
DeclaredType: declaredType);
return true;
}
default:
// SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
// the runtime cannot read.
return false;
}
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string.</summary>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>
/// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
/// boolean yields its raw token — so an authored <c>whereValue</c> of either shape is captured
/// verbatim for later parameter binding. Null when absent or not a scalar.
/// </summary>
private static string? ReadScalar(JsonElement o, string name)
{
if (!o.TryGetProperty(name, out var e)) return null;
return e.ValueKind switch
{
JsonValueKind.String => e.GetString(),
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
_ => null,
};
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
Oracle,
}
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel
{
/// <summary>
/// Key-value (EAV) table: one row per tag, selected by <c>keyColumn = keyValue</c>, value read from
/// <c>valueColumn</c>.
/// </summary>
KeyValue,
/// <summary>
/// Wide row: one row holds many signals as columns; the tag names its <c>columnName</c> and the row is
/// picked by a row selector (a <c>whereColumn</c>/<c>whereValue</c> pair, or newest-by-timestamp).
/// </summary>
WideRow,
/// <summary>Named arbitrary-SELECT query (design §5.4). <b>Deferred to P3 — not accepted by v1's parser.</b></summary>
Query,
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's <c>TagConfig</c> blob
/// (design §5.2 / §5.3), produced by <see cref="SqlEquipmentTagParser.TryParse"/>.
/// <para><b>Every string here is captured verbatim from the authored blob and is NEVER concatenated
/// into SQL.</b> Value-bearing fields (<see cref="KeyValue"/>, <see cref="RowSelectorValue"/>) are bound
/// as <c>DbParameter</c>s by the reader; identifier-bearing fields (<see cref="Table"/>,
/// <see cref="KeyColumn"/>, <see cref="ValueColumn"/>, <see cref="TimestampColumn"/>,
/// <see cref="ColumnName"/>, <see cref="RowSelectorColumn"/>,
/// <see cref="RowSelectorTopByTimestamp"/>) must be validated against <c>INFORMATION_SCHEMA</c> and
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
/// legitimate values, so the parser deliberately does not.</para>
/// </summary>
/// <param name="Name">
/// The tag's identity — its <b>RawPath</b>, exactly as handed to the parser. The driver's forward
/// router keys published values on this, so it must never be derived from the blob's contents.
/// </param>
/// <param name="Model">Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.</param>
/// <param name="Table">The table or view to read (e.g. <c>dbo.TagValues</c>).</param>
/// <param name="KeyColumn"><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</param>
/// <param name="KeyValue"><see cref="SqlTagModel.KeyValue"/>: this tag's key — <b>bound</b>, never interpolated.</param>
/// <param name="ValueColumn"><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</param>
/// <param name="TimestampColumn">Optional source-timestamp column; absent ⇒ the driver stamps the poll time.</param>
/// <param name="ColumnName"><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</param>
/// <param name="RowSelectorColumn"><see cref="SqlTagModel.WideRow"/>: the <c>whereColumn</c> that picks the row.</param>
/// <param name="RowSelectorValue"><see cref="SqlTagModel.WideRow"/>: the <c>whereValue</c> — <b>bound</b>, never interpolated.</param>
/// <param name="RowSelectorTopByTimestamp"><see cref="SqlTagModel.WideRow"/>: newest-row selector — the timestamp column to order by, when no where-pair is authored.</param>
/// <param name="DeclaredType">
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
/// driver infers the type from the result set's column metadata.
/// </param>
public sealed record SqlTagDefinition(
string Name,
SqlTagModel Model,
string Table,
string? KeyColumn = null,
string? KeyValue = null,
string? ValueColumn = null,
string? TimestampColumn = null,
string? ColumnName = null,
string? RowSelectorColumn = null,
string? RowSelectorValue = null,
string? RowSelectorTopByTimestamp = null,
DriverDataType? DeclaredType = null);
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,123 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
/// connections, commands, parameters and readers; they do <b>not</b> abstract the three things that differ
/// per backend — <b>identifier quoting</b>, the <b>metadata-catalog SQL</b>, and <b>row-limit syntax</b>.
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
/// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
/// <see cref="DbProviderFactory"/>).</para>
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
SqlProvider Provider { get; }
/// <summary>
/// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the
/// abstract base type so consumers never bind to a concrete provider package.
/// </summary>
DbProviderFactory Factory { get; }
/// <summary>
/// Quotes <b>one</b> identifier part so it can be safely embedded in a command text, escaping the
/// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather
/// than emitting it.
/// <para><b>One part only.</b> A multi-part name such as <c>dbo.TagValues</c> must be split by the
/// caller and each part quoted separately; passing the dotted form yields a single (nonexistent)
/// identifier — safe, but not what the caller meant.</para>
/// </summary>
/// <param name="ident">The bare, unquoted identifier — as returned by the catalog, not pre-quoted.</param>
/// <returns>The quoted identifier, ready to concatenate into a command text.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
string QuoteIdentifier(string ident);
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
string LivenessSql { get; }
/// <summary>
/// The fragment that goes <b>immediately after <c>SELECT</c></b> to limit a statement to one row —
/// T-SQL's <c>"TOP 1 "</c>. Empty for a dialect that spells the limit at the end of the statement (see
/// <see cref="SingleRowLimitSuffix"/>).
/// <para><b>Include the trailing space</b> when non-empty: the planner concatenates
/// <c>"SELECT " + SingleRowLimitPrefix + columns</c> with no separator of its own, so an empty fragment
/// must leave the statement byte-identical to an unlimited one.</para>
/// </summary>
/// <remarks>
/// <para><b>Why a prefix/suffix pair rather than one <c>ApplyRowLimit(sql, n)</c> method.</b> The two
/// spellings sit at opposite ends of the statement — SQL Server writes
/// <c>SELECT TOP 1 … ORDER BY … DESC</c>, while SQLite/Postgres/MySQL write
/// <c>SELECT … ORDER BY … DESC LIMIT 1</c>. A single method taking the SELECT list could only serve the
/// prefix position; a single method taking the whole statement would move statement assembly out of the
/// planner and into every dialect. Two fragments keep the planner the sole author of statement shape
/// and let each dialect fill in the end it uses.</para>
/// <para><b>Why "single row" rather than a row count.</b> The only limit v1 emits is the wide-row
/// <c>topByTimestamp</c> selector's newest-row pick. Modelling an arbitrary <c>n</c> would be untested
/// surface, and <c>FETCH FIRST</c>/<c>ROWNUM</c> dialects need more than a substituted number anyway.
/// Widen this to a method when a feature actually needs <c>n &gt; 1</c>.</para>
/// </remarks>
string SingleRowLimitPrefix { get; }
/// <summary>
/// The fragment appended to the <b>very end</b> of a statement to limit it to one row —
/// <c>" LIMIT 1"</c> for SQLite/Postgres/MySQL. Empty for a dialect that limits after <c>SELECT</c>
/// (see <see cref="SingleRowLimitPrefix"/>).
/// <para><b>Include the leading space</b> when non-empty, for the same reason the prefix carries a
/// trailing one: the planner appends it directly to the finished statement.</para>
/// <para>Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
/// emits <b>both</b> unconditionally — a dialect needing both ends (or neither) is expressible without
/// touching the call site.</para>
/// </summary>
string SingleRowLimitSuffix { get; }
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; }
/// <summary>
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
/// — the same dialect resolves differently for two different logins.</para>
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
string ListTablesSql { get; }
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
string ListColumnsSql { get; }
/// <summary>
/// Folds a catalog data-type family name (as <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> reports it —
/// e.g. <c>nvarchar</c>, never <c>nvarchar(50)</c>) onto a <see cref="DriverDataType"/>.
/// <para>Must <b>never throw</b>: a browse over a table holding one exotic column must still render.
/// An unrecognised family falls back to <see cref="DriverDataType.String"/>.</para>
/// </summary>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
DriverDataType MapColumnType(string sqlDataType);
}
@@ -0,0 +1,137 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
/// allow-list design §8.1 requires identifiers to come from.
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
/// identifier text in an emitted query is a string the database gave us.</para>
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
/// asks.</para>
/// </summary>
public sealed class SqlCatalog
{
private readonly IReadOnlyList<string> _schemas;
/// <summary>Canonical schema → canonical table names in it.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
/// <summary>Constructs a catalog snapshot.</summary>
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
/// <param name="schemas">Every schema the connection can see.</param>
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
internal SqlCatalog(
string defaultSchema,
IReadOnlyList<string> schemas,
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
{
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
_columnsByTable = columnsByTable;
}
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
public string DefaultSchema { get; }
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
public bool TryResolveSchema(string? authored, out string canonical)
{
if (string.IsNullOrWhiteSpace(authored))
{
canonical = DefaultSchema;
return true;
}
return TryMatch(_schemas, authored, out canonical);
}
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
/// <param name="authored">The authored table or view name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
{
canonical = string.Empty;
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
&& TryMatch(tables, authored, out canonical);
}
/// <summary>Resolves an authored column name within a canonical relation.</summary>
/// <param name="canonicalSchema">The resolved schema.</param>
/// <param name="canonicalTable">The resolved table or view.</param>
/// <param name="authored">The authored column name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
public bool TryResolveColumn(
string canonicalSchema, string canonicalTable, string authored, out string canonical)
{
canonical = string.Empty;
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
&& TryMatch(columns, authored, out canonical);
}
/// <summary>The dictionary key for a canonical relation.</summary>
/// <param name="schema">The canonical schema.</param>
/// <param name="table">The canonical table.</param>
/// <returns>The composite key.</returns>
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
/// <summary>
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
/// </summary>
/// <remarks>
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
/// ordinally would reject configurations that have always been valid — a gate that breaks working
/// deployments is not defence in depth.</para>
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
/// such a config resolving to what the operator wrote, and refusing to choose between two
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
/// </remarks>
/// <param name="candidates">The catalog spellings to match against.</param>
/// <param name="authored">The authored name.</param>
/// <param name="canonical">The catalog's spelling, when matched.</param>
/// <returns><see langword="true"/> on an unambiguous match.</returns>
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
{
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
canonical = candidate;
return true;
}
canonical = string.Empty;
var matches = 0;
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
if (++matches > 1)
{
canonical = string.Empty;
return false;
}
canonical = candidate;
}
return matches == 1;
}
}
@@ -0,0 +1,241 @@
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
/// shape and the caller's tests can assert on the reason.
/// </summary>
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
/// <param name="Accepted">
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
/// <see cref="SqlGroupPlanner"/>.
/// </param>
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
public sealed record SqlCatalogGateResult(
IReadOnlyList<SqlTagDefinition> Accepted,
IReadOnlyList<SqlCatalogRejection> Rejected);
/// <summary>
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
/// </summary>
/// <remarks>
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
/// sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
/// than the whole story.</para>
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
/// must, or an operator has no way to find the typo.</para>
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
/// driver's table, so its RawPath resolves to nothing and it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
/// down" rule the tag-table build already follows.</para>
/// </remarks>
public static class SqlCatalogGate
{
/// <summary>
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
/// cannot see, so it cannot be allow-listed.
/// </summary>
private const int MaxNameParts = 2;
/// <summary>
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
/// with catalog-spelled identifiers and the rejected ones with reasons.
/// </summary>
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
/// <param name="catalog">The catalog snapshot to validate against.</param>
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
/// <returns>The accepted and rejected sets.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
public static SqlCatalogGateResult Apply(
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(definitions);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(dialect);
var accepted = new List<SqlTagDefinition>();
var rejected = new List<SqlCatalogRejection>();
foreach (var definition in definitions)
{
ArgumentNullException.ThrowIfNull(definition);
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
rejected.Add(rejection!);
}
return new SqlCatalogGateResult(accepted, rejected);
}
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
private static bool TryCanonicalize(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out SqlTagDefinition? canonical,
out SqlCatalogRejection? rejection)
{
canonical = null;
rejection = null;
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
return false;
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
// model does not use is null and simply skipped — the planner enforces which are required.
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var (field, authored) in new (string Field, string? Authored)[]
{
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
})
{
if (string.IsNullOrWhiteSpace(authored))
{
resolved[field] = authored;
continue;
}
if (!IsRenderableIdentifier(authored, dialect))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
"or Unicode format characters); the value is withheld from this message because it " +
"cannot be safely rendered");
return false;
}
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
"column under a case-sensitive collation)");
return false;
}
resolved[field] = column;
}
canonical = definition with
{
Table = SqlCatalog.QualifiedKey(schema, table),
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
};
return true;
}
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
private static bool TryResolveRelation(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out string schema,
out string table,
out SqlCatalogRejection? rejection)
{
schema = string.Empty;
table = string.Empty;
rejection = null;
const string Field = nameof(SqlTagDefinition.Table);
if (string.IsNullOrWhiteSpace(definition.Table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
"the tag has no 'table'; author the table or view to read");
return false;
}
var parts = definition.Table.Split('.');
if (parts.Length > MaxNameParts)
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
"validated against this connection's catalog, so a cross-database or linked-server name " +
"cannot be allow-listed; expose the data through a view in this database instead");
return false;
}
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
var authoredTable = parts[^1];
foreach (var part in parts)
{
if (IsRenderableIdentifier(part, dialect)) continue;
rejection = new SqlCatalogRejection(definition.Name, Field,
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
"containing control or Unicode format characters); the value is withheld from this message " +
"because it cannot be safely rendered");
return false;
}
if (!catalog.TryResolveSchema(authoredSchema, out schema))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
"case-sensitive collation)");
return false;
}
if (!catalog.TryResolveTable(schema, authoredTable, out table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
(authoredSchema is null
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
: string.Empty));
return false;
}
return true;
}
/// <summary>
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
/// embed in SQL and to render into a log line or an AdminUI label.
/// </summary>
/// <remarks>
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
/// discarded: only whether it threw is interesting here.
/// </remarks>
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
{
try
{
dialect.QuoteIdentifier(identifier);
return true;
}
catch (ArgumentException)
{
return false;
}
}
}
@@ -0,0 +1,189 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
/// dialect's catalog SQL (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
/// one <see cref="ISqlDialect.ListColumnsSql"/> per distinct authored relation. A driver polling three
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
/// confidently-empty address space.</para>
/// </remarks>
internal static class SqlCatalogLoader
{
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
/// <param name="dialect">Supplies the catalog SQL.</param>
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
/// <param name="commandTimeout">Per-command server-side backstop.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The loaded catalog.</returns>
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
internal static async Task<SqlCatalog> LoadAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyCollection<string> authoredTables,
TimeSpan commandTimeout,
CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
connection, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
// that basis would report an authoring fault for what is actually a grant problem, and would send the
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
if (schemas.Count == 0)
throw new InvalidOperationException(
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
"the catalog views, which is a grant problem rather than a tag-authoring one");
var defaultSchema = await ReadDefaultSchemaAsync(
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
// one table and validating against another would be a silent wrong-column defect.
foreach (var authored in authoredTables)
{
if (string.IsNullOrWhiteSpace(authored)) continue;
var parts = authored.Split('.');
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
var authoredTable = parts[^1];
string schema;
if (parts.Length == 2)
{
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
}
else
{
schema = defaultSchema;
}
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
connection, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, "@schema", schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
var key = SqlCatalog.QualifiedKey(schema, table);
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
connection, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
}
/// <summary>
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
/// query answers null/blank.
/// </summary>
/// <remarks>
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
/// visible schema keeps the common one-schema database working; with several visible schemas there is
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
/// </remarks>
private static async Task<string> ReadDefaultSchemaAsync(
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var schema = value is null or DBNull ? null : value.ToString();
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
}
}
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
string sql,
string columnName,
Action<DbCommand> bind,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var results = new List<string>();
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
var ordinal = -1;
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal)) continue;
var value = reader.GetValue(ordinal)?.ToString();
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
}
}
}
return results;
}
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
}
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Resolves a <c>Sql</c> driver config's <c>connectionStringRef</c> — a NAME — into the connection string
/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never
/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff.
/// <para><b>The environment is read directly, on purpose.</b> A driver factory is invoked through
/// <c>DriverFactoryRegistry</c>'s <c>(driverInstanceId, driverConfigJson)</c> closure — there is no
/// <c>IConfiguration</c>, no <c>IServiceProvider</c> and no ambient scope at that seam, and widening the
/// registry's signature for one driver would change every driver in the tree. The
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c> spelling is exactly the key .NET's environment-variable
/// configuration provider would bind to <c>Sql:ConnectionStrings:&lt;ref&gt;</c>, so an operator can set it
/// the same way they set every other secret and a later move onto <c>IConfiguration</c> needs no
/// re-provisioning.</para>
/// <para><b>Nothing here ever emits the resolved value.</b> Messages name the environment
/// <em>variable</em>, which is not a secret and is the operator's next action; the value itself is returned
/// to exactly one caller and is otherwise unmentioned.</para>
/// </summary>
public static class SqlConnectionStringResolver
{
/// <summary>
/// The environment-variable prefix a <c>connectionStringRef</c> is appended to. The double underscore
/// is .NET's configuration hierarchy separator, so this is the environment spelling of
/// <c>Sql:ConnectionStrings:&lt;ref&gt;</c>.
/// </summary>
public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
/// <summary>The environment variable a given <c>connectionStringRef</c> is read from.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
public static string EnvironmentVariableFor(string connectionStringRef)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
}
/// <summary>
/// Resolves <paramref name="connectionStringRef"/> to its connection string.
/// <para>An absent <b>or blank</b> variable is a half-provisioned deployment and throws: handing an
/// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll,
/// far from the missing secret that actually caused it.</para>
/// </summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The resolved connection string. <b>Treat as a secret</b> — never log it.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">The environment variable is absent or blank.</exception>
public static string Resolve(string connectionStringRef)
{
var variable = EnvironmentVariableFor(connectionStringRef);
var value = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
$"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " +
$"variable '{variable}' on every node that runs this driver. The connection string is " +
$"deliberately not carried in the deployed configuration.");
}
return value;
}
}
@@ -0,0 +1,937 @@
using System.Data.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
/// <see cref="SqlPollReader"/> owns the read path.
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
/// new capability, not a flag flip.</para>
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
/// <see cref="IReadable"/> literally: it throws only when the database cannot be reached, and turns
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
/// <em>successfully</em>, so <see cref="PollGroupEngine"/> sees a clean tick, does not back off, and does
/// not call <see cref="HandlePollError"/>. Without <see cref="ObservePollOutcome"/> below, a driver whose
/// every value is <see cref="SqlStatusCodes.BadTimeout"/> would keep reporting
/// <see cref="DriverState.Healthy"/>. See that method for the exact rule and for why it does not
/// manufacture an exception to force backoff.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
{
/// <summary>
/// The driver-type string this driver registers under — <see cref="DriverTypeNames.Sql"/>, the single
/// source of truth the deploy pipeline stores in <c>DriverInstance.DriverType</c> and every dispatch
/// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so
/// <c>DriverTypeNamesGuardTests</c>' bidirectional-parity check is satisfied).
/// </summary>
public const string DriverTypeName = DriverTypeNames.Sql;
/// <summary>Shown for a connection string that names no recognisable server.</summary>
private const string UnknownEndpoint = "(unknown sql endpoint)";
/// <summary>Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>Connection-string keys that name the server, in the order they are consulted.</summary>
private static readonly string[] ServerKeys =
["server", "data source", "datasource", "host", "address", "addr", "network address"];
/// <summary>Connection-string keys that name the database.</summary>
private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
// ---- instance fields (grouped at top for auditability) ----
private readonly SqlDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ISqlDialect _dialect;
private readonly DbProviderFactory _factory;
/// <summary>
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
/// </summary>
private readonly string _connectionString;
private readonly ILogger<SqlDriver> _logger;
/// <summary>
/// The authored RawPath → definition table, held as an <b>immutable snapshot swapped atomically</b>
/// rather than as a mutated dictionary (the shape <c>ModbusDriver</c> uses).
/// <see cref="ReinitializeAsync"/> rebuilds this table while poll loops are reading it; clearing and
/// refilling a shared <see cref="Dictionary{TKey,TValue}"/> under that concurrency is a torn read at
/// best and an <see cref="InvalidOperationException"/> inside a poll at worst.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
/// reach a query.
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
/// node: §8.1's specified outcome is that it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, and a status code can only be published by a node
/// that is there. Dropping rejected tags from the authored table too would delete the node instead,
/// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse
/// failure for the operator trying to find their typo.</para>
/// <para>Swapped atomically, for the same reason the authored table is.</para>
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary>
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
/// delegate must read this driver's live authored table, which does not exist before the driver does.
/// </summary>
private readonly SqlPollReader _reader;
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
private readonly PollGroupEngine _poll;
// Single logical host: one driver instance dials one connection string. HostName is the endpoint
// description (server[/database]) so the Admin UI shows operators where to look.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- ctor + identity ----
/// <summary>Initializes a new <c>Sql</c> driver instance.</summary>
/// <param name="options">The driver's typed configuration (the factory applies the documented defaults).</param>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="dialect">
/// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
/// </param>
/// <param name="connectionString">
/// The <b>already-resolved</b> connection string. The driver never resolves a
/// <c>connectionStringRef</c> itself and never logs this value.
/// </param>
/// <param name="factory">
/// Creates the provider's connections. Defaults to <paramref name="dialect"/>'s factory; passed
/// explicitly by tests that need to observe or delay connection creation.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
public SqlDriver(
SqlDriverOptions options,
string driverInstanceId,
ISqlDialect dialect,
string connectionString,
DbProviderFactory? factory = null,
ILogger<SqlDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(dialect);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
_options = options;
_driverInstanceId = driverInstanceId;
_dialect = dialect;
_factory = factory ?? dialect.Factory;
_connectionString = connectionString;
_logger = logger ?? NullLogger<SqlDriver>.Instance;
Endpoint = DescribeEndpoint(connectionString);
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
_reader = new SqlPollReader(
_factory,
connectionString,
dialect,
commandTimeout: options.CommandTimeout,
operationTimeout: options.OperationTimeout,
maxConcurrentGroups: options.MaxConcurrentGroups,
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
logger: _logger);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => DriverTypeName;
/// <summary>
/// The credential-free description of the database this instance polls — <c>server/database</c> where
/// the connection string names both. This is the only rendering of the connection string that may
/// appear in a log, a health message, or the Admin UI.
/// </summary>
public string Endpoint { get; }
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
public string HostName => Endpoint;
/// <summary>Active polled-subscription count. Diagnostics + disposal assertions.</summary>
internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
/// <summary>
/// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
/// <summary>
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
/// rejected must miss here so the reader publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
/// </summary>
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
// ---- IDriver lifecycle ----
/// <summary>
/// Builds the authored RawPath table and proves the database is reachable.
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
/// (<see cref="ApplyCatalogGateAsync"/>), which is the second and last piece of I/O. It runs after
/// liveness because it needs a working connection, and before the driver reports Healthy because the
/// tag table it publishes must be the validated one — a poll must never see an unvalidated
/// identifier.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
BuildTagTable();
try
{
await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller tore the initialization down; not a database verdict.
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
throw;
}
catch (Exception ex)
{
// Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is
// dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's
// message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an
// unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only
// the exception TYPE reaches LastError/host status; the full exception object goes to the log
// SINK via the structured logger's exception parameter (below), never into a rendered string.
var message =
$"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " +
$"Check the database is reachable and the connection string is valid.";
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
// Separate try from liveness so the operator surface names the stage that actually failed: "could
// not reach the database" and "reached it but could not read its catalog" send an operator to
// different places, and the second is usually a GRANT rather than an outage.
try
{
await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
throw;
}
catch (Exception ex)
{
// Same discipline as above: exception TYPE only on the operator surface, full exception to the
// log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of
// evidence — so this faults the driver (and retries) rather than rejecting every tag and serving
// a confidently-empty address space.
var message =
$"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " +
$"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " +
"can read the catalog views.";
_logger.LogError(ex,
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
_logger.LogInformation(
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
_driverInstanceId, Endpoint, Tags.Count);
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
/// <returns>Always zero.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <summary>
/// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
/// could only produce the same nodes.
/// </summary>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>
/// False — <see cref="DiscoverAsync"/> replays authored tags rather than enumerating the backend, so
/// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
/// a separate surface: <c>Driver.Sql.Browser</c>.)
/// </summary>
public bool SupportsOnlineDiscovery => false;
/// <summary>
/// Streams the authored tags as read-only variables.
/// <para>An undeclared tag materialises as <see cref="DriverDataType.String"/> — the same fallback
/// <see cref="ISqlDialect.MapColumnType"/> uses for an unrecognised column type — because a column's
/// real type is only known once a poll has returned result-set metadata, which has not happened at
/// discovery time. Author <c>"type"</c> on a numeric tag.</para>
/// </summary>
/// <param name="builder">The address-space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task — discovery is synchronous over the authored table.</returns>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder(DriverTypeName, DriverTypeName);
foreach (var tag in Tags.Values)
{
if (tag.DeclaredType is null)
{
// Discovery cannot infer a column's real type — that needs live result-set metadata, which
// only a poll returns — so an omitted-type tag materialises as String (the same fallback
// ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual,
// possibly numeric, CLR value against that String node. Nothing else signals the operator to
// this declared-vs-published mismatch, so warn and point them at the fix.
_logger.LogWarning(
"Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " +
"will then publish numeric values against a String node. Author an explicit \"type\" on " +
"numeric tags. Driver={DriverInstanceId}",
tag.Name, _driverInstanceId);
}
folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
IsArray: false,
ArrayDim: null,
// v1 is read-only: no tag, and no configuration, can widen this.
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
return Task.CompletedTask;
}
// ---- IReadable ----
/// <summary>
/// Reads a batch, delegating to <see cref="SqlPollReader"/> and classifying what came back
/// (<see cref="ObservePollOutcome"/>).
/// </summary>
/// <param name="fullReferences">The RawPaths to read.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="DbException">The database could not be reached — the engine backs off on this.</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
// unreachable" and degrade a perfectly healthy driver.
ArgumentNullException.ThrowIfNull(fullReferences);
try
{
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
ObservePollOutcome(snapshots);
return snapshots;
}
catch (OperationCanceledException)
{
// Teardown, not a database verdict — leave health and host state exactly as they were.
throw;
}
catch (Exception ex)
{
// The reader throws for one reason only: opening a connection failed (IReadable's contract).
_logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
_driverInstanceId, Endpoint);
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
// on the log sink via the LogWarning exception parameter above.
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
throw;
}
}
// ---- ISubscribable (polling overlay via the shared engine) ----
/// <summary>
/// Registers a polled subscription.
/// <para>A non-positive <paramref name="publishingInterval"/> falls back to the configured
/// <see cref="SqlDriverOptions.DefaultPollInterval"/>; anything faster than
/// <see cref="PollGroupEngine.DefaultMinInterval"/> is floored by the engine.</para>
/// </summary>
/// <param name="fullReferences">The RawPaths to poll.</param>
/// <param name="publishingInterval">The requested publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The subscription handle.</returns>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
return Task.FromResult(_poll.Subscribe(fullReferences, interval));
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// The single logical host this instance dials. There is no background probe loop: the state is a
/// by-product of the Initialize-time liveness check and of every poll, so the report is always a
/// statement about traffic that actually happened rather than about a synthetic ping.
/// </summary>
/// <returns>A one-element list describing the configured database endpoint.</returns>
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
// ---- health + host-state classification ----
/// <summary>
/// Classifies one poll's snapshots into a health verdict and a host state.
/// <list type="bullet">
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
/// host is Running.</item>
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
/// the host is Stopped.</item>
/// <item>Bad codes that are <b>authoring</b> facts — an unresolvable RawPath, an absent row, a
/// type mismatch, a rejected definition — change nothing. A tag typo must never report the
/// database as down and send an operator to the wrong system.</item>
/// </list>
/// <para><b>Why this does not throw to force poll-engine backoff.</b> Backoff would require turning
/// an all-<c>BadTimeout</c> batch into an exception, and the engine's exception path publishes
/// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
/// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
/// already bounded by <c>operationTimeout</c> and the reader never holds more than
/// <c>maxConcurrentGroups</c> connections however long the database stays frozen. So a frozen
/// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
/// cadence rather than backed off from.</para>
/// </summary>
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
{
if (snapshots.Count == 0) return;
var good = 0;
var unreachable = 0;
foreach (var snapshot in snapshots)
{
if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
}
if (unreachable > 0)
{
Degrade(
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
$"{Endpoint} on the last poll.");
if (good > 0)
{
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
TouchLastSuccessfulRead();
TransitionHostTo(HostState.Running);
}
else
{
TransitionHostTo(HostState.Stopped);
}
return;
}
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
// to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
}
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
private static bool IsConnectionClass(uint statusCode)
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
/// <summary>
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
/// touch host state: the engine also reports its own contract violations here, which say nothing
/// about connectivity — the unreachable-database transition is raised by <see cref="ReadAsync"/>,
/// which knows the exception came from the reader.
/// </summary>
/// <param name="ex">The exception the poll engine caught.</param>
internal void HandlePollError(Exception ex)
{
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
// guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
if (ex is DbException) return;
// Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
// (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
// credential fragments) — the operator surface carries only the exception TYPE, never ex.Message.
// The full exception, with its text, still reaches the log SINK via the exception parameter. This is
// the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check.
_logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
_driverInstanceId, Endpoint);
Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}.");
}
/// <summary>
/// Degrades health, preserving <c>LastSuccessfulRead</c> and never downgrading
/// <see cref="DriverState.Faulted"/> — a config-level verdict that only a successful read or an
/// operator reinitialize may clear.
/// </summary>
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
private void Degrade(string reason)
{
var current = ReadHealth();
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
}
/// <summary>
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
/// <see cref="ReinitializeAsync"/> just faulted.
/// </summary>
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
private void SetHealthUnlessFaulted(DriverHealth value)
{
if (ReadHealth().State == DriverState.Faulted) return;
WriteHealth(value);
}
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
private void TouchLastSuccessfulRead()
{
var current = ReadHealth();
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
}
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
private void TransitionHostTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
// ---- authored tag table ----
/// <summary>
/// Maps every authored <see cref="RawTagEntry"/> through <see cref="SqlEquipmentTagParser.TryParse"/>
/// and publishes the result as one atomic snapshot. A blob that does not map is <b>logged and
/// skipped</b>, never thrown: one malformed tag must not take the whole driver — and therefore every
/// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
/// publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> rather than someone else's row.
/// </summary>
private void BuildTagTable()
{
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
foreach (var entry in _options.RawTags)
{
try
{
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
table[entry.RawPath] = definition;
else
_logger.LogWarning(
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
catch (Exception ex)
{
// TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's
// try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
// branch above follows — rather than fault the driver over a single entry.
_logger.LogError(
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
Volatile.Write(ref _tagsByRawPath, table);
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
// previous generation's validated table in place) means a Reinitialize whose gate then fails cannot
// keep polling the OLD identifiers against a database whose schema may be exactly what changed.
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
}
// ---- catalog gate (design §8.1) ----
/// <summary>
/// Validates every authored identifier against the live catalog and republishes the tag table with
/// catalog spellings, dropping the tags that do not resolve (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Why this must run before the driver reports Healthy.</b> The table this swaps in is what
/// every poll resolves against. Running it later — or in the background — would leave a window in
/// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate
/// exists to close.</para>
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
/// typo must not stop the other tags on that database, the same rule
/// <see cref="BuildTagTable"/> follows for a malformed blob. Each drop is logged at Warning with the
/// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is
/// unsupportable.</para>
/// <para><b>No tags ⇒ no I/O.</b> A driver with nothing authored has nothing to validate, and issuing
/// catalog queries to prove that would be a round-trip that can only fail.</para>
/// <para>Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work
/// runs on the thread pool so a provider implementing the async path synchronously blocks a pool
/// thread rather than wedging Initialize forever with no retry.</para>
/// </remarks>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken)
{
var authored = Tags;
if (authored.Count == 0) return;
var tables = authored.Values
.Select(definition => definition.Table)
.Where(table => !string.IsNullOrWhiteSpace(table))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = await RunBoundedAsync(
token => LoadCatalogAsync(tables, token),
_options.OperationTimeout,
"the catalog queries did not return",
cancellationToken).ConfigureAwait(false);
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
foreach (var rejection in result.Rejected)
{
_logger.LogWarning(
"Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish "
+ "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}",
rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId);
}
var validated = new Dictionary<string, SqlTagDefinition>(result.Accepted.Count, StringComparer.Ordinal);
foreach (var definition in result.Accepted) validated[definition.Name] = definition;
// Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps
// its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath).
Volatile.Write(ref _polledByRawPath, validated);
_logger.LogInformation(
"Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) "
+ "across {Tables} table(s) on {Endpoint}.",
_driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint);
}
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
private async Task<SqlCatalog> LoadCatalogAsync(
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return await SqlCatalogLoader
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
.ConfigureAwait(false);
}
}
// ---- liveness ----
/// <summary>
/// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
/// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
/// long-lived connection here would only be a second thing to keep alive.
/// <para><b>Bounded by wall-clock, not only by the token</b> (the R2-01 / STAB-14 lesson): some ADO.NET
/// providers implement the async path synchronously, and a wedged socket can hang inside the
/// provider's own cancellation handshake. If Initialize hung there, <c>DriverInstanceActor</c>'s init
/// task would never complete and the driver would sit in Connecting forever with no retry. The work
/// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
/// an abandoned attempt still owns and disposes its own connection.</para>
/// </summary>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the database has answered.</returns>
private Task VerifyLivenessAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(
async token => { await PingAsync(token).ConfigureAwait(false); return true; },
_options.CommandTimeout,
"the liveness statement did not return",
cancellationToken);
/// <summary>
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
/// </summary>
/// <remarks>
/// <para>The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists
/// because a token is not a deadline: some ADO.NET providers implement the async path synchronously,
/// and a wedged socket can hang <em>inside</em> the provider's own cancellation handshake. Without an
/// independent wall clock, <c>DriverInstanceActor</c>'s init task would never complete and the driver
/// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped.</para>
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
/// </remarks>
/// <typeparam name="T">The work's result type.</typeparam>
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
/// <param name="budget">The wall-clock allowance.</param>
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>The work's result.</returns>
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
private static async Task<T> RunBoundedAsync<T>(
Func<CancellationToken, Task<T>> work,
TimeSpan budget,
string what,
CancellationToken cancellationToken)
{
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<T> running;
try
{
deadline.CancelAfter(budget);
running = Task.Run(
async () =>
{
try { return await work(deadline.Token).ConfigureAwait(false); }
finally { deadline.Dispose(); }
},
CancellationToken.None);
}
catch
{
// Ownership of the CTS transfers to the work task; release it only if that task never started.
deadline.Dispose();
throw;
}
try
{
return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
Detach(running);
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Our deadline fired and the provider DID honour the linked token.
Detach(running);
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
}
}
/// <summary>Opens a connection and executes the dialect's cheapest proof-of-life statement.</summary>
private async Task PingAsync(CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = _dialect.LivenessSql;
// ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
/// task exception. The task still owns — and disposes — its own connection.
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
// ---- endpoint description ----
/// <summary>
/// Renders a connection string as <c>server/database</c>, reading only those two keys.
/// <para>This is the <b>only</b> function permitted to look at the connection string outside the
/// provider call, and it exists so that no other code is ever tempted to log the string itself:
/// credentials live in <c>User ID</c> / <c>Password</c> / <c>Authentication</c>, which are never
/// read here. A string that cannot be parsed yields <see cref="UnknownEndpoint"/> — an unusable
/// description must not become a crash at construction.</para>
/// </summary>
/// <param name="connectionString">The resolved connection string.</param>
/// <returns>A credential-free description safe to log and display.</returns>
private static string DescribeEndpoint(string connectionString)
{
try
{
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
var server = FirstValue(builder, ServerKeys);
if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
var database = FirstValue(builder, DatabaseKeys);
return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
}
catch (ArgumentException)
{
return UnknownEndpoint;
}
}
/// <summary>Reads the first of <paramref name="keys"/> the builder carries; null when it carries none.</summary>
private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
{
foreach (var key in keys)
{
// DbConnectionStringBuilder's key comparison is case-insensitive.
if (builder.TryGetValue(key, out var value) && value is not null)
{
var text = value.ToString();
if (!string.IsNullOrWhiteSpace(text)) return text;
}
}
return null;
}
// ---- teardown ----
/// <summary>
/// Performs the same teardown as <see cref="ShutdownAsync"/>, so a caller that only uses
/// <c>await using</c> does not leak the poll loops.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
/// <summary>
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
/// to call any number of times, in any order, which is what makes
/// <c>ShutdownAsync</c>-then-<c>DisposeAsync</c> (and <see cref="ReinitializeAsync"/>) safe.
/// <para>There is no connection to close: the reader opens and disposes one per poll.</para>
/// </summary>
private async Task TeardownAsync()
{
await _poll.DisposeAsync().ConfigureAwait(false);
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
}
}
@@ -0,0 +1,252 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
/// <c>operationTimeout</c>/<c>commandTimeout</c> pair on purpose (the frozen-database tests need that
/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
/// its two independent deadlines. Every knob is checked here, once, at construction.</para>
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
/// rendering — never the string itself.</para>
/// </summary>
public static class SqlDriverFactoryExtensions
{
/// <summary>
/// The driver-type string this factory registers under — chained off
/// <see cref="SqlDriver.DriverTypeName"/>, which is
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
/// </summary>
public const string DriverTypeName = SqlDriver.DriverTypeName;
/// <summary>
/// How a <c>Sql</c> config blob is read.
/// <list type="bullet">
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
/// ordinal, and always writes the name (the enum-serialization guard).</item>
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
/// schema version must not brick the driver.</item>
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
/// what makes a <em>write</em> round-trip to the authored shape.</item>
/// </list>
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
/// <summary>
/// Registers the <c>Sql</c> factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
/// driver instance; without it the driver runs with the null logger.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
/// exist until the driver does.</para>
/// <para>No I/O happens here. The database is first contacted by
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
/// Reconnecting rather than a deployment that cannot be constructed.</para>
/// </summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
/// </exception>
public static SqlDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
// secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
var options = BuildOptions(dto, driverInstanceId);
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
if (dto.AllowWrites is true)
{
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
// on, however, can — so this is loud.
logger.LogWarning(
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
"driver is read-only (it implements no write capability), so no configuration can enable a " +
"write. Remove the flag, or expect reads only.",
driverInstanceId);
}
var driver = new SqlDriver(
options,
driverInstanceId,
dialect,
connectionString,
factory: null,
logger: loggerFactory?.CreateLogger<SqlDriver>());
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
// permitted outside the provider call.
logger.LogInformation(
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
options.DefaultPollInterval, options.OperationTimeout);
return driver;
}
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' deserialised to null");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
}
}
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
/// </summary>
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
=> provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
_ => throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
};
/// <summary>
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
/// <para>Internal so the defaults and the rejections can be asserted directly, without a database or a
/// provisioned connection string standing between the test and the rule.</para>
/// </summary>
/// <param name="dto">The deserialised config blob.</param>
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
/// <returns>The typed options the driver is constructed with.</returns>
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
{
ArgumentNullException.ThrowIfNull(dto);
var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
if (maxConcurrentGroups < 1)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
$"it must be at least 1.");
}
// Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
// server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
// wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
// first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
// two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
if (operationTimeout <= commandTimeout)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
$"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
$"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
$"never fire.");
}
return new SqlDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
DefaultPollInterval = defaultPollInterval,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
MaxConcurrentGroups = maxConcurrentGroups,
NullIsBad = dto.NullIsBad ?? false,
};
}
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
{
if (value <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
}
}
}
@@ -0,0 +1,56 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The typed form of a <c>Sql</c> driver instance's configuration — what
/// <see cref="SqlDriverConfigDto"/> becomes once the factory has applied its defaults (design §5.1).
/// Mirrors <c>ModbusDriverOptions</c>: the driver instance is constructed with these, and its
/// <see cref="SqlDriver.InitializeAsync"/> serves them rather than re-parsing the config JSON.
/// <para><b>No connection string here.</b> The resolved connection string is a separate constructor
/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.</para>
/// </summary>
public sealed class SqlDriverOptions
{
/// <summary>
/// The authored raw tags this driver serves. The deploy artifact hands each authored raw <c>Tag</c>
/// as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob); the driver maps
/// each through <see cref="SqlEquipmentTagParser.TryParse"/> into its RawPath → definition table.
/// A SQL source has no tag-discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Poll interval used when a subscriber does not ask for one (design §4: <c>defaultPollInterval</c>,
/// default 5 s). A subscriber that names an interval gets that interval, floored by
/// <see cref="PollGroupEngine.DefaultMinInterval"/>.
/// </summary>
public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
/// publishes <see cref="SqlStatusCodes.BadTimeout"/> for that group's tags.
/// </summary>
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
/// <summary>
/// The ADO.NET <c>CommandTimeout</c> server-side backstop (default 10 s), also the bound on the
/// Initialize-time liveness check.
/// <para>Authoring must keep <see cref="OperationTimeout"/> strictly greater than this (design §8.3);
/// inverted, the client-side abort always fires first and masks the backstop. <b>That rule is
/// enforced by config validation in the factory, not here</b> — the reader deliberately stays usable
/// with the order inverted so the frozen-database tests can prove the client-side bound fires.</para>
/// </summary>
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>Cap on concurrently executing group queries — and therefore on concurrently open connections.</summary>
public int MaxConcurrentGroups { get; init; } = 4;
/// <summary>
/// When <see langword="true"/>, a present row whose value cell is NULL publishes
/// <see cref="SqlStatusCodes.Bad"/> instead of the default <see cref="SqlStatusCodes.Uncertain"/>.
/// It never governs an absent row, which is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </summary>
public bool NullIsBad { get; init; }
}
@@ -0,0 +1,171 @@
using System.Data.Common;
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
/// <para><b>Parses the SAME factory DTO with the SAME converter as
/// <see cref="SqlDriverFactoryExtensions"/></b> (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
/// <c>provider</c> parses here exactly as it does at the factory, and the same
/// <c>connectionStringRef</c> resolves the same way.</para>
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
/// catch.</para>
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
/// </summary>
public sealed class SqlDriverProbe : IDriverProbe
{
/// <summary>
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
/// probe can run against the SQLite fixture with no SQL Server and no network.
/// </summary>
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
/// <summary>
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
/// catalog SQL. Null in production: the dialect's own factory is used.
/// </summary>
private readonly DbProviderFactory? _factoryOverride;
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
public SqlDriverProbe()
: this(DefaultDialectFor, factoryOverride: null)
{
}
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
{
_dialectFor = dialectFor;
_factoryOverride = factoryOverride;
}
/// <summary>
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
/// runs unmodified.
/// </summary>
/// <param name="factory">The provider factory the probe opens connections from.</param>
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
/// <returns>A probe wired to the supplied factory + dialect.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
return new SqlDriverProbe(_ => dialect, factory);
}
/// <inheritdoc />
public string DriverType => SqlDriver.DriverTypeName;
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
SqlDriverConfigDto? dto;
try
{
dto = System.Text.Json.JsonSerializer.Deserialize<SqlDriverConfigDto>(
configJson, SqlDriverFactoryExtensions.JsonOptions);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
{
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);
ISqlDialect dialect;
string connectionString;
try
{
dialect = _dialectFor(dto.Provider);
connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
}
catch (InvalidOperationException ex)
{
// Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
// safe and actionable (it names the environment variable the operator must set).
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
var factory = _factoryOverride ?? dialect.Factory;
return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
}
/// <summary>
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
/// data source, so its <c>.Message</c> is deliberately not surfaced).
/// </summary>
private static async Task<DriverProbeResult> RunLivenessAsync(
DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
CancellationToken ct)
{
var stopwatch = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);
try
{
var connection = factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = connectionString;
await connection.OpenAsync(cts.Token).ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
}
stopwatch.Stop();
return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return new DriverProbeResult(false, "Probe cancelled.", null);
}
catch (OperationCanceledException)
{
return new DriverProbeResult(
false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
// The provider's own message can carry the data source (host/database), which is not a secret but
// is more than the operator needs — and keeping the message to a fixed class is what guarantees no
// credential-bearing connection string can ever slip through. Name the exception TYPE only.
return new DriverProbeResult(
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
}
}
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
_ => throw new InvalidOperationException(
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
};
}
@@ -0,0 +1,250 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Folds a set of <see cref="SqlTagDefinition"/>s into the minimum number of parameterized queries
/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> when every
/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
/// <c>SqlGroupPlannerTests</c>.</para>
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
/// interpolation of tag content into SQL anywhere in this type.</para>
/// </summary>
public static class SqlGroupPlanner
{
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
private const string KeyMarkerPrefix = "@k";
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
private const string RowSelectorMarker = "@w";
/// <summary>
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
/// <para>Deterministic: groups appear in the input order of their first member, members keep their input
/// order within a group, and parameters keep the first-appearance order of their distinct values — so a
/// given tag list always yields byte-identical SQL.</para>
/// </summary>
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
/// <returns>One plan per distinct group key.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
/// <exception cref="ArgumentException">
/// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
/// identifier cannot be quoted (an empty table-name part, a control character — see
/// <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
/// </exception>
/// <exception cref="NotSupportedException">
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
/// <b>Deliberately loud rather than skipped:</b> silently dropping a tag would leave an authored node
/// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
/// </exception>
public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(dialect);
// Ordered grouping: the dictionary decides membership, the list decides emission order.
var order = new List<object>();
var groups = new Dictionary<object, List<SqlTagDefinition>>();
foreach (var tag in tags)
{
ArgumentNullException.ThrowIfNull(tag);
var key = BuildGroupKey(tag);
if (!groups.TryGetValue(key, out var members))
{
members = [];
groups.Add(key, members);
order.Add(key);
}
members.Add(tag);
}
var plans = new List<SqlQueryPlan>(order.Count);
foreach (var key in order)
{
var members = groups[key];
plans.Add(members[0].Model switch
{
SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
_ => throw UnsupportedModel(members[0]),
});
}
return plans;
}
/// <summary>
/// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose
/// first element is the model, so a key-value key can never compare equal to a wide-row key.
/// <list type="bullet">
/// <item>
/// <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
/// Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
/// would make one tag publish the other's column.
/// </item>
/// <item>
/// <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
/// — i.e. the table plus the whole row selector, exactly design §5.3's
/// <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
/// deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
/// one row), and they are added to the SELECT list instead.
/// </item>
/// </list>
/// <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
/// two tags authored <c>dbo.T</c> and <c>DBO.T</c> plan as two groups. That costs one extra round-trip
/// and is never wrong; guessing the server's collation here could fold two genuinely different sources.</para>
/// </summary>
private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch
{
SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn),
SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue,
tag.RowSelectorTopByTimestamp),
_ => throw UnsupportedModel(tag),
};
/// <summary>
/// Emits <c>SELECT &lt;key&gt;, &lt;value&gt;[, &lt;ts&gt;] FROM &lt;table&gt; WHERE &lt;key&gt; IN (@k0..@kN)</c>.
/// The key column is selected because the reader indexes rows by it to slice values back per member.
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
/// </summary>
private static SqlQueryPlan BuildKeyValuePlan(
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
{
var first = members[0];
var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first);
var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first);
var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn;
var selected = new List<string>(3);
AddDistinct(selected, keyColumn);
AddDistinct(selected, valueColumn);
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
var names = new List<string>(members.Count);
var values = new List<object?>(members.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
{
var keyValue = member.KeyValue
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
if (!seen.Add(keyValue)) continue;
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
values.Add(keyValue);
}
var sql = new StringBuilder("SELECT ")
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", names).Append(')')
.ToString();
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
}
/// <summary>
/// Emits <c>SELECT &lt;cols&gt; FROM &lt;table&gt; WHERE &lt;whereColumn&gt; = @w</c>, or — for a
/// <c>topByTimestamp</c> selector — the single-row form
/// <c>SELECT &lt;limit&gt; &lt;cols&gt; FROM &lt;table&gt; ORDER BY &lt;ts&gt; DESC &lt;limit&gt;</c>, whose
/// row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
/// <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
/// row in the result already matches the bound value, so reading it back adds nothing.</para>
/// </summary>
private static SqlQueryPlan BuildWideRowPlan(
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
{
var first = members[0];
var selected = new List<string>(members.Count + 1);
foreach (var member in members)
AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member));
foreach (var member in members)
if (!Blank(member.TimestampColumn))
AddDistinct(selected, member.TimestampColumn!);
var table = QuoteQualifiedName(first.Table, dialect);
var columns = QuoteList(selected, dialect);
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
var sql = string.Concat(
"SELECT ", columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
}
if (!Blank(first.RowSelectorTopByTimestamp))
{
// The row limit is dialect syntax and sits at opposite ends of the statement per provider
// (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted
// unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix.
var sql = string.Concat(
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
dialect.SingleRowLimitSuffix);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
}
throw new ArgumentException(
$"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" +
"whereValue pair or a rowSelector.topByTimestamp column.", nameof(members));
}
/// <summary>
/// Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
/// <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
/// <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
/// form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
/// empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
/// whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
/// </summary>
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
{
if (Blank(name))
throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name));
return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
}
/// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
=> string.Join(", ", columns.Select(dialect.QuoteIdentifier));
/// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
private static void AddDistinct(List<string> target, string value)
{
if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
}
/// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
/// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag)
=> Blank(value) ? throw MissingField(field, tag) : value!;
private static ArgumentException MissingField(string field, SqlTagDefinition tag)
=> new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'.");
private static NotSupportedException UnsupportedModel(SqlTagDefinition tag)
=> new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " +
"v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4).");
}
@@ -0,0 +1,839 @@
using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Executes one poll pass: resolves each requested reference to a <see cref="SqlTagDefinition"/>, folds
/// the definitions into the minimum number of queries via <see cref="SqlGroupPlanner"/>, runs one
/// command per group under a bounded deadline, and slices each result set back to per-tag
/// <see cref="DataValueSnapshot"/>s — <b>positionally aligned with the caller's reference list</b>
/// (design §3.2). Structurally the SQL analogue of <c>ModbusDriver.ReadCoalescedAsync</c>: group → one
/// round-trip → slice back.
/// <para><b>N in, N out, in order.</b> The returned list always has exactly one entry per requested
/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never
/// exceptions (the <see cref="IReadable"/> contract) — see <see cref="SqlStatusCodes"/> for which code
/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags,
/// so a slicing defect does not fail loudly — it publishes <em>one tag's value onto another tag's
/// node</em>.</para>
/// <para><b>The whole call throws only when the database is unreachable</b> — that is, when opening a
/// connection fails. That surfaces to <see cref="PollGroupEngine"/> as a poll failure and earns the
/// capped-exponential backoff; the driver's <c>IHostConnectivityProbe</c> is what scopes Bad quality
/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for
/// an outage. A query that fails <em>after</em> the connection opened is the group's problem, not the
/// server's, and Bad-codes that group only.</para>
/// <para><b>Deadlines (design §8.3, the R2-01 frozen-peer lesson).</b> Two independent mechanisms, both
/// required. <c>CommandTimeout</c> is the <em>server-side</em> backstop and bounds nothing on a wedged
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
/// <see cref="RunGroupAsync"/>. A frozen database yields <see cref="SqlStatusCodes.BadTimeout"/>
/// snapshots — it must never wedge the poll thread.</para>
/// <para><b>Not a driver.</b> This type owns the read path only; it deliberately holds no health state,
/// no subscription state and no connection between polls, so the driver shell can wrap it in
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
/// </summary>
public sealed class SqlPollReader
{
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
private readonly DbProviderFactory _factory;
private readonly string _connectionString;
private readonly ISqlDialect _dialect;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly bool _nullIsBad;
private readonly Func<string, SqlTagDefinition?> _resolve;
private readonly ILogger _logger;
/// <summary>
/// Caps concurrently executing group queries — and therefore concurrently held connections
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
/// timed-out-but-still-running group releasing into a disposed semaphore.
/// </summary>
private readonly SemaphoreSlim _gate;
private long _lastContractWarningTicks;
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
/// <param name="factory">
/// Creates the provider's connections. Passed explicitly rather than taken from
/// <paramref name="dialect"/> so the driver can hand in an instrumented or pre-configured factory.
/// </param>
/// <param name="connectionString">
/// The resolved connection string (never the authored <c>connectionStringRef</c> — secrets are
/// resolved by the driver at Initialize, design §8.2).
/// </param>
/// <param name="dialect">Supplies identifier quoting, row-limit syntax, and column-type mapping.</param>
/// <param name="commandTimeout">
/// The ADO.NET <c>CommandTimeout</c> backstop. Rounded <b>up</b> to whole seconds and floored at 1,
/// because ADO.NET reads <c>CommandTimeout = 0</c> as "wait forever" — the one value this option
/// must never silently become.
/// </param>
/// <param name="operationTimeout">
/// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening
/// the connection, and running the query. A breach Bad-codes that group with
/// <see cref="SqlStatusCodes.BadTimeout"/>.
/// <para>Authoring should keep this strictly greater than <paramref name="commandTimeout"/>
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
/// backstop. That rule is <b>not</b> enforced here — it belongs to config validation, and this type
/// stays usable for the tests that must deliberately invert it.</para>
/// </param>
/// <param name="maxConcurrentGroups">
/// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot
/// until it truly finishes, so this is a hard ceiling on connections even against a frozen database.
/// See <see cref="RunGroupAsync"/> for why a high value is safe under
/// <c>Microsoft.Data.SqlClient</c> but wants sizing against thread-pool headroom under a provider
/// that blocks synchronously.
/// </param>
/// <param name="nullIsBad">
/// <see langword="true"/> publishes <see cref="SqlStatusCodes.Bad"/> for a NULL value cell instead
/// of the default <see cref="SqlStatusCodes.Uncertain"/>. It governs a <em>present</em> row with a
/// NULL cell only — an absent row is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </param>
/// <param name="resolve">
/// RawPath → tag definition; <see langword="null"/> on a miss. Shaped to match
/// <see cref="EquipmentTagRefResolver{TDef}"/>'s lookup so the driver can pass its authored table
/// straight through.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is blank.</exception>
/// <exception cref="ArgumentOutOfRangeException">A timeout is non-positive, or the cap is below 1.</exception>
public SqlPollReader(
DbProviderFactory factory,
string connectionString,
ISqlDialect dialect,
TimeSpan commandTimeout,
TimeSpan operationTimeout,
int maxConcurrentGroups,
bool nullIsBad,
Func<string, SqlTagDefinition?> resolve,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
ArgumentNullException.ThrowIfNull(resolve);
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString));
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1);
_factory = factory;
_connectionString = connectionString;
_dialect = dialect;
_operationTimeout = operationTimeout;
_commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
_nullIsBad = nullIsBad;
_resolve = resolve;
_logger = logger ?? NullLogger.Instance;
_gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups);
}
/// <summary>
/// Reads every reference in one poll pass. Matches <see cref="IReadable.ReadAsync"/>'s shape so the
/// driver shell can delegate to it directly.
/// </summary>
/// <param name="fullReferences">The RawPaths to read. Empty yields an empty result and touches no connection.</param>
/// <param name="cancellationToken">
/// The caller's token. Its cancellation <b>propagates</b> as an
/// <see cref="OperationCanceledException"/> — the engine asked the poll to stop, and nobody is
/// waiting for the snapshots. This is deliberately asymmetric with an <c>operationTimeout</c>
/// breach, which is a data-quality fact clients must see and so becomes
/// <see cref="SqlStatusCodes.BadTimeout"/> snapshots.
/// </param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fullReferences"/> is null.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
if (results.Length == 0) return results;
var readAt = DateTime.UtcNow;
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
// whose members are the sole thing the slice-back walks.
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
var slots = new Dictionary<SqlTagDefinition, List<int>>();
for (var i = 0; i < fullReferences.Count; i++)
{
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
if (definition is null)
{
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
continue;
}
definitions.Add(definition);
if (!slots.TryGetValue(definition, out var positions))
slots[definition] = positions = [];
positions.Add(i);
}
if (definitions.Count > 0)
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
for (var i = 0; i < results.Length; i++)
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
return results;
}
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
private async Task ReadGroupsAsync(
List<SqlTagDefinition> definitions,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
IReadOnlyList<SqlQueryPlan> plans;
try
{
plans = SqlGroupPlanner.Plan(definitions, _dialect);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
// reaching here means a definition was built past the parser. It is an authoring fault, not a
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
definitions.Count);
foreach (var definition in definitions)
{
if (!slots.TryGetValue(definition, out var positions)) continue;
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
return;
}
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
// carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by
// plans.Count × operationTimeout.
var groups = new Task<GroupResult>[plans.Count];
for (var g = 0; g < plans.Count; g++)
groups[g] = RunGroupAsync(plans[g], cancellationToken);
// WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never
// leaves a sibling group running against a connection nobody is watching.
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
for (var g = 0; g < plans.Count; g++)
Slice(plans[g], outcomes[g], slots, results, readAt);
}
/// <summary>
/// Runs one group's query under a hard wall-clock ceiling.
/// <para><b>Why the ceiling is <c>Task.WaitAsync</c> and not just a linked
/// <c>CancelAfter</c>.</b> A linked token only bounds a
/// provider that <em>honours</em> it. The S7 R2-01 finding was exactly a provider whose async path
/// ignored its deadline, and ADO.NET has the same shape: some providers implement
/// <c>ExecuteReaderAsync</c> synchronously, and even a genuinely async one can hang inside its own
/// cancellation handshake against a frozen socket. Both are used here: the linked token so a
/// well-behaved provider unwinds cleanly and releases its connection, and <c>WaitAsync</c> so
/// control returns at the deadline regardless.</para>
/// <para>The work is started on the thread pool so a provider that blocks synchronously blocks a
/// pool thread rather than the caller — without that, a synchronous provider never yields the task
/// there would be to bound.</para>
/// <para><b>Thread-pool scheduling and what a BadTimeout therefore means.</b> The group's clock
/// starts before <c>Task.Run</c>, so in principle a group could burn budget queueing for a pool
/// thread rather than waiting on the database. <c>TaskCreationOptions.LongRunning</c> (a dedicated
/// OS thread) was considered and <b>rejected</b>: it would create and tear down one thread per group
/// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost
/// paid against a hazard that cannot arise with the provider this driver actually ships.
/// <c>Microsoft.Data.SqlClient</c>'s async path is genuinely asynchronous: the delegate reaches its
/// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks
/// <em>zero</em> pool threads however long it stays frozen, and the driver cannot starve itself no
/// matter how high <c>maxConcurrentGroups</c> goes. The queueing scenario needs a provider that
/// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose,
/// and is not a shipped configuration. <b>Consequence for outage diagnosis (e.g. blackhole-testing a
/// paused SQL Server):</b> under <c>Microsoft.Data.SqlClient</c> a
/// <see cref="SqlStatusCodes.BadTimeout"/> means the database did not answer inside
/// <c>operationTimeout</c> — it cannot be an artefact of this driver's own pool pressure. Under a
/// synchronously-blocking provider it may also include queueing delay, so size
/// <c>maxConcurrentGroups</c> below the process's readily-available worker count there.</para>
/// <para><b>The concurrency slot is released by the work, not by the waiter.</b> A timed-out group
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
/// <c>maxConcurrentGroups</c> connections however long it stays frozen. The slot wait is itself
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
/// </summary>
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false))
return GroupResult.TimedOut;
var handedOff = false;
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<GroupResult> work;
try
{
var budget = Remaining(clock);
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
deadline.CancelAfter(budget);
work = Task.Run(
async () =>
{
try
{
return await QueryAsync(plan, deadline.Token).ConfigureAwait(false);
}
finally
{
_gate.Release();
deadline.Dispose();
}
},
CancellationToken.None);
handedOff = true;
}
finally
{
// Ownership of both the slot and the CTS transfers to the work task; release them here only on
// the paths where that task was never created.
if (!handedOff)
{
_gate.Release();
deadline.Dispose();
}
}
try
{
return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Our wall-clock bound fired while the provider was still inside the call.
return TimedOut(plan, work);
}
catch (OperationCanceledException)
{
// Either the caller pulled the plug, or our deadline fired and the provider DID honour the
// linked token. Both leave the work running with nobody to observe its fault.
if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work);
Detach(work);
throw;
}
}
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
private TimeSpan Remaining(Stopwatch clock)
{
var remaining = _operationTimeout - clock.Elapsed;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>Records the deadline breach and detaches the abandoned work.</summary>
private GroupResult TimedOut(SqlQueryPlan plan, Task work)
{
// The table is named for the same reason the contract warnings name it: under a partial outage this
// line repeats every poll, and "which source is frozen" is the only question the operator has.
_logger.LogWarning(
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
"timeout; publishing BadTimeout.",
plan.Model, plan.Members[0].Table, plan.Members.Count,
(int)_operationTimeout.TotalMilliseconds);
Detach(work);
return GroupResult.TimedOut;
}
/// <summary>
/// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task
/// exception. The task still owns its connection and still releases the concurrency slot when it
/// finally completes — that is what keeps a frozen database from accumulating connections.
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
/// <summary>
/// Opens a connection, executes the plan's single command, and materialises the result set.
/// <para>The rows are read into memory <b>before</b> the reader is disposed — the slice-back needs
/// random access across members, and holding a reader (and therefore a connection) open across that
/// work is exactly how a poll loop exhausts a pool.</para>
/// </summary>
private async Task<GroupResult> QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"The {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
// Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which
// is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff).
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
try
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = plan.SqlText;
command.CommandTimeout = _commandTimeoutSeconds;
for (var i = 0; i < plan.ParameterNames.Count; i++)
{
var parameter = command.CreateParameter();
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
parameter.ParameterName = plan.ParameterNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
command.Parameters.Add(parameter);
}
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// The connection opened, so the server is up — this query is what failed (an identifier that
// is not in the catalog, a lock, a permission). That is this group's problem alone.
_logger.LogWarning(
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
plan.Model, plan.Members.Count, plan.SqlText);
return GroupResult.Failed;
}
}
}
/// <summary>
/// Reads every row into memory, projected onto <see cref="SqlQueryPlan.SelectedColumns"/>. Row
/// fetching stays on the async path so a result set that streams slowly still honours the deadline
/// token mid-read, rather than only at the ExecuteReader boundary.
/// </summary>
private async Task<GroupResult> MaterialiseAsync(
SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken)
{
var selected = plan.SelectedColumns;
var ordinals = new int[selected.Count];
var typeNames = new string[selected.Count];
var columnIndex = new Dictionary<string, int>(selected.Count, StringComparer.Ordinal);
for (var c = 0; c < selected.Count; c++)
{
// GetOrdinal rather than the position in SelectedColumns: the list is documented to be in
// ordinal order, but resolving it against the live result set is what makes a wrong slice
// impossible rather than merely unlikely.
ordinals[c] = reader.GetOrdinal(selected[c]);
typeNames[c] = SafeTypeName(reader, ordinals[c]);
columnIndex[selected[c]] = c;
}
var rows = new List<object?[]>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var row = new object?[selected.Count];
for (var c = 0; c < selected.Count; c++)
row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]);
rows.Add(row);
}
return GroupResult.Ok(rows, columnIndex, typeNames);
}
/// <summary>Reads a column's provider type name, falling back to the dialect's unknown-type default.</summary>
private static string SafeTypeName(DbDataReader reader, int ordinal)
{
try
{
return reader.GetDataTypeName(ordinal) ?? string.Empty;
}
catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException
or IndexOutOfRangeException)
{
// A provider that declines to report a type name must not fail the poll — the tag's declared
// type (or the dialect's String fallback) covers it.
return string.Empty;
}
}
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
private void Slice(
SqlQueryPlan plan,
GroupResult outcome,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt)
{
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
// they are group-wide facts, and doing them per member would report a contract violation N times.
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
? IndexByKey(plan, outcome)
: null;
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
? SelectWideRow(plan, outcome)
: null;
foreach (var member in plan.Members)
{
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
if (!slots.TryGetValue(member, out var positions)) continue;
var snapshot = outcome.Succeeded
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
}
/// <summary>
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
/// violation is reported through a rate-limited warning rather than silently.
/// </summary>
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
{
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
return index;
var duplicated = 0;
foreach (var row in outcome.Rows)
{
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
// matched by a tag whose keyValue is legitimately the empty string.
if (row[keyAt] is not { } keyCell) continue;
// Stringified because the bound keyValue is authored text while the column may be any type
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
if (key is null) continue;
if (!index.TryAdd(key, row))
{
index[key] = row;
duplicated++;
}
}
if (duplicated > 0 && ShouldWarnAboutContract())
{
_logger.LogWarning(
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
"last row wins. The key-value model requires one row per key — point the tag at a " +
"current-values table or an operator-authored latest-per-key view.",
plan.Members[0].Table, duplicated);
}
return index;
}
/// <summary>
/// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector.
/// <para><b>Last row wins</b> — the same tie-break <see cref="IndexByKey"/> applies, so both models
/// degrade the same way. It is deterministic only <em>within one result set</em>: the where-pair form
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
/// (<c>Rows.Count &gt; 1</c>), converting a loud misconfiguration into a permanently silent one —
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
/// form keeps its row limit because there the <c>ORDER BY</c> makes "the first row" a defined
/// answer. The accepted cost is that an ambiguous selector materialises every matching row for one
/// poll; the warning is what gets it fixed.</para>
/// </summary>
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
{
if (outcome.Rows.Count == 0) return null;
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
{
var first = plan.Members[0];
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
? string.Concat("topByTimestamp ", first.RowSelectorTopByTimestamp)
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
_logger.LogWarning(
"Sql poll source '{Table}' returned {Rows} rows for the wide-row selector '{Selector}' in " +
"one poll; the last row read wins, and the query carries no ORDER BY, so which row that is " +
"depends on storage order. The wide-row model requires a selector matching exactly one row " +
"— narrow the whereColumn/whereValue pair, or point the tag at a latest-row view.",
first.Table, outcome.Rows.Count, selector);
}
return outcome.Rows[^1];
}
/// <summary>Maps one member's cell to a snapshot: value, quality, and source timestamp.</summary>
private DataValueSnapshot MapMember(
SqlQueryPlan plan,
SqlTagDefinition member,
GroupResult outcome,
Dictionary<string, object?[]>? byKey,
object?[]? wideRow,
DateTime readAt)
{
object?[]? row;
string? valueColumn;
string? timestampColumn;
if (plan.Model == SqlTagModel.KeyValue)
{
valueColumn = plan.ValueColumn;
timestampColumn = plan.TimestampColumn;
row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null;
}
else
{
valueColumn = member.ColumnName;
// A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are
// selected), which is why the plan-level TimestampColumn is null for this model by design.
timestampColumn = member.TimestampColumn;
row = wideRow;
}
// No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell.
if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt);
// A timestamp that will not parse falls back to the poll clock rather than poisoning a good value.
var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt;
if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt))
{
// The planner selects every member's value column, so this is unreachable short of plan/result
// drift — loud rather than silently publishing someone else's cell.
_logger.LogError(
"Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.",
member.Name, valueColumn);
return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt);
}
var cell = row[valueAt];
if (cell is null)
{
return new DataValueSnapshot(
null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt);
}
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
if (!TryCoerce(cell, target, out var value))
{
_logger.LogWarning(
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
member.Name, cell.GetType().Name, target);
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
}
return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt);
}
/// <summary>Reads a row's source timestamp, or null when there is no column, no value, or no parse.</summary>
private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn)
{
if (string.IsNullOrWhiteSpace(timestampColumn)) return null;
if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null;
if (row[at] is not { } cell) return null;
return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null;
}
/// <summary>
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
/// type matches the type the address space declared for the node. Never throws.
/// </summary>
private static bool TryCoerce(object cell, DriverDataType target, out object? value)
{
try
{
value = target switch
{
DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture),
DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture),
DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture),
DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture),
DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture),
DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture),
// decimal/numeric collapse here, with the documented precision caveat (design §8.6).
DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture),
DriverDataType.DateTime => ToUtc(cell),
_ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty,
};
return true;
}
catch (Exception ex) when (ex is InvalidCastException or FormatException
or OverflowException or ArgumentException)
{
value = null;
return false;
}
}
/// <summary>
/// Normalises a timestamp cell to UTC. A naive <c>datetime</c> (no offset — the common SQL Server
/// shape) is <b>assumed</b> to already be UTC rather than reinterpreted through the server's local
/// zone, which would silently shift every source timestamp by the host's offset.
/// </summary>
private static DateTime ToUtc(object cell) => cell switch
{
DateTime { Kind: DateTimeKind.Utc } utc => utc,
DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(),
DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc),
DateTimeOffset offset => offset.UtcDateTime,
string text => DateTime.Parse(
text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
_ => DateTime.SpecifyKind(
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
};
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
private bool ShouldWarnAboutContract()
{
var now = Stopwatch.GetTimestamp();
var last = Interlocked.Read(ref _lastContractWarningTicks);
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
}
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
private sealed class GroupResult
{
private static readonly IReadOnlyList<object?[]> NoRows = [];
private static readonly IReadOnlyDictionary<string, int> NoColumns =
new Dictionary<string, int>(StringComparer.Ordinal);
private GroupResult(
uint failureStatus,
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
{
FailureStatus = failureStatus;
Rows = rows;
ColumnIndex = columnIndex;
TypeNames = typeNames;
}
/// <summary>The group exceeded its wall-clock deadline (design §8.3).</summary>
public static GroupResult TimedOut { get; } =
new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []);
/// <summary>The connection was fine but the query was not.</summary>
public static GroupResult Failed { get; } =
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
public uint FailureStatus { get; }
/// <summary>The materialised rows, projected onto the plan's selected columns.</summary>
public IReadOnlyList<object?[]> Rows { get; }
/// <summary>Selected column name → its position within each row array.</summary>
public IReadOnlyDictionary<string, int> ColumnIndex { get; }
/// <summary>Each selected column's provider type name, for dialect type inference.</summary>
public IReadOnlyList<string> TypeNames { get; }
/// <summary>True when the query ran; a zero-row result set is still a success.</summary>
public bool Succeeded => FailureStatus == SqlStatusCodes.Good;
/// <summary>A completed result set.</summary>
/// <param name="rows">The materialised rows.</param>
/// <param name="columnIndex">Selected column name → row-array position.</param>
/// <param name="typeNames">Each selected column's provider type name.</param>
/// <returns>A successful outcome.</returns>
public static GroupResult Ok(
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
=> new(SqlStatusCodes.Good, rows, columnIndex, typeNames);
}
}
/// <summary>
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
/// them back with.
/// <para><b>Why a driver-local table rather than a shared one.</b> Every driver in this tree carries its
/// own (<c>TwinCATStatusMapper</c>, <c>AbCipStatusMapper</c>, <c>FocasStatusMapper</c>,
/// <c>AbLegacyStatusMapper</c>, Galaxy's <c>StatusCodeMap</c>) — there is no shared helper in
/// <c>Core.Abstractions</c>, because drivers deliberately do not reference the OPC UA SDK and
/// <see cref="DataValueSnapshot.StatusCode"/> is a bare <see cref="uint"/>. The values below were read
/// off <c>Opc.Ua.StatusCodes</c> rather than copied from a sibling driver, since two of those tables
/// carry transcription errors.</para>
/// </summary>
public static class SqlStatusCodes
{
/// <summary>The value is good. (<c>Good</c>)</summary>
public const uint Good = 0x00000000u;
/// <summary>Quality is uncertain with no more specific reason — a NULL cell under <c>nullIsBad=false</c>.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Quality is bad with no more specific reason — a NULL cell under <c>nullIsBad=true</c>.</summary>
public const uint Bad = 0x80000000u;
/// <summary>
/// No row was returned for this tag — the key is absent from the result set, or the wide-row
/// selector matched nothing. <b>Deliberately distinct from a NULL cell:</b> the row is gone, versus
/// the row is there and the value is not.
/// </summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>The group exceeded its client-side wall-clock deadline (design §8.3).</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>The reference resolves to no authored tag.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The query failed after the connection opened.</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The cell could not be coerced to the tag's effective data type.</summary>
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>A tag definition the planner rejected — an authoring fault, not a database fault.</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>A defect in the reader itself; never expected in the field.</summary>
public const uint BadInternalError = 0x80020000u;
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
private const uint SeverityMask = 0xC0000000u;
/// <summary>True when <paramref name="statusCode"/> is in the Good quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Good.</returns>
public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u;
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Uncertain.</returns>
public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain;
/// <summary>True when <paramref name="statusCode"/> is in the Bad quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Bad.</returns>
public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad;
}
@@ -0,0 +1,90 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
/// shares a <see cref="GroupKey"/> reads from the <b>same</b> result set, so a poll pass executes one plan
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
/// <param name="Model">
/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
/// <see cref="SqlTagModel.KeyValue"/> indexes rows by <see cref="KeyColumn"/> and matches each member's
/// <see cref="SqlTagDefinition.KeyValue"/>; <see cref="SqlTagModel.WideRow"/> takes the single row and
/// reads each member's <see cref="SqlTagDefinition.ColumnName"/>.
/// </param>
/// <param name="GroupKey">
/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly
/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing.
/// </param>
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
/// <param name="ParameterNames">
/// The parameter markers appearing in <see cref="SqlText"/>, in the order they appear, aligned index-for-index
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
/// may legitimately read the same cell, and both must receive a value.
/// </param>
/// <param name="SelectedColumns">
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// </param>
public sealed record SqlQueryPlan(
SqlTagModel Model,
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
{
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
// mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary.
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
/// <summary>The tags this plan feeds, in input order; duplicates preserved.</summary>
public IReadOnlyList<SqlTagDefinition> Members { get; } = [.. Members];
/// <summary>The bare SELECT-list column names, in result-set ordinal order.</summary>
public IReadOnlyList<string> SelectedColumns { get; } = [.. SelectedColumns];
/// <summary>
/// The key column all members are matched on — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant,
/// so <c>Members[0]</c> is authoritative.
/// </summary>
public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
/// <summary>
/// The column every member's value is read from — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant.
/// </summary>
public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
/// <summary>
/// The shared source-timestamp column, or <see langword="null"/> when the group has none (the reader
/// then stamps the poll time). <see cref="SqlTagModel.KeyValue"/> only — it is part of that model's
/// group key, so it is uniform. A <see cref="SqlTagModel.WideRow"/> plan deliberately allows members to
/// carry <em>different</em> timestamp columns (all of them are selected), so the reader must read
/// <see cref="SqlTagDefinition.TimestampColumn"/> per member there.
/// </summary>
public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
}
@@ -0,0 +1,159 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class SqlServerDialect : ISqlDialect
{
/// <summary>
/// T-SQL's regular-identifier ceiling (<c>sysname</c> = <c>nvarchar(128)</c>). Enforced because a
/// longer string cannot name a real catalog object — it is either a config mistake or padding, and
/// rejecting it keeps the quoted output bounded.
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.SqlServer;
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
/// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
/// separator at the call site.
/// </summary>
public string SingleRowLimitPrefix => "TOP 1 ";
/// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
/// <summary>
/// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
/// </summary>
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
/// <inheritdoc/>
public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
/// <inheritdoc/>
public string ListColumnsSql =>
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION";
/// <summary>
/// Brackets one identifier part, doubling any embedded <c>]</c> — the T-SQL escape rule, and the only
/// metacharacter that could terminate a bracketed identifier early. <c>[</c>, quotes, semicolons and
/// comment markers are inert inside brackets and are therefore passed through unchanged.
/// </summary>
/// <remarks>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="System.Globalization.UnicodeCategory.Format"/> character — bidi overrides, zero-width
/// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the <em>rendered</em>
/// text of a log line or an AdminUI label while comparing byte-different from the real catalog name
/// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the
/// control-character rule.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The bracket-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQL Server identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
if (ident.Length > MaxIdentifierLength)
throw new ArgumentException(
$"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
// zero-width character. They are inert inside brackets but spoof rendered log/UI text.
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
throw new ArgumentException(
"A SQL identifier may not contain Unicode format characters "
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
}
return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]");
}
/// <summary>
/// Folds an <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> family name onto a
/// <see cref="DriverDataType"/> per design §3.7.
/// </summary>
/// <remarks>
/// <para><b>Precision caveat:</b> <c>decimal</c> / <c>numeric</c> / <c>money</c> / <c>smallmoney</c>
/// collapse to <see cref="DriverDataType.Float64"/> in v1. A .NET <c>decimal</c> carries more
/// significant digits than a <c>double</c>, so a high-precision column (e.g. <c>decimal(38,10)</c>)
/// loses low-order digits. Authoring an explicit <c>"type": "String"</c> on the tag preserves the exact
/// textual value when that matters.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>xml</c>,
/// <c>geography</c>, <c>hierarchyid</c>, <c>sql_variant</c>, a CLR UDT, <c>varbinary</c>) rather than
/// crash on it. Null/blank input takes the same fallback.</para>
/// <para><b>Deliberate non-mappings:</b> SQL Server's <c>timestamp</c>/<c>rowversion</c> is an opaque
/// 8-byte row version, <em>not</em> a date — it falls back to <see cref="DriverDataType.String"/>
/// rather than being mistaken for a <see cref="DriverDataType.DateTime"/>. <c>time</c> is a
/// time-of-day span, likewise not a point in time, and takes the same fallback.</para>
/// <para><c>tinyint</c> widens to <see cref="DriverDataType.Int16"/> (it is unsigned 0255 in T-SQL, so
/// a signed 8-bit type would not hold it, and the driver type set has no <c>Byte</c>).</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"bit" or "boolean" => DriverDataType.Boolean,
"tinyint" or "smallint" => DriverDataType.Int16,
"int" or "integer" => DriverDataType.Int32,
"bigint" => DriverDataType.Int64,
"real" => DriverDataType.Float32,
"float" or "double" or "double precision"
or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64,
"char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext"
or "uniqueidentifier" => DriverDataType.String,
"date" or "datetime" or "datetime2" or "smalldatetime"
or "datetimeoffset" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
public const uint BadOutOfService = 0x80BE0000u;
public const uint BadInvalidState = 0x80350000u;
public const uint BadTypeMismatch = 0x80740000u;
public const uint BadOutOfService = 0x808D0000u;
public const uint BadInvalidState = 0x80AF0000u;
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
@@ -81,7 +81,7 @@ else
<tr>
<td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
<td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
@@ -58,7 +58,7 @@ else
}
else
{
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
@if (_lastDeployment.SealedAtUtc is not null)
@@ -55,7 +55,7 @@
{
<tr>
<td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td>
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td>
<td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td>
@@ -113,7 +113,7 @@
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch
{
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.",
@@ -38,7 +38,7 @@ else
<span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Rendering helpers for operator-facing text that comes out of the database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
/// </para>
/// <para>
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
/// <c>h1…</c>, so the display never implies more text than exists.
/// </para>
/// </remarks>
public static class DisplayText
{
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
/// Admin UI already uses for "nothing to show" cells.</summary>
public const string Empty = "—";
/// <summary>
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
/// ellipsis when (and only when) characters were dropped.
/// </summary>
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
/// <paramref name="maxLength"/> — none of which throw.</param>
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
/// so a caller cannot turn a display bug into a crash.</param>
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
/// otherwise the truncated prefix plus an ellipsis.</returns>
public static string Abbreviate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return Empty;
if (maxLength < 1) maxLength = 1;
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
}
}
@@ -57,6 +57,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -36,6 +36,7 @@
<option value="Focas">Focas</option>
<option value="OpcUaClient">OpcUaClient</option>
<option value="GalaxyMxGateway">Galaxy</option>
<option value="Sql">Sql</option>
</InputSelect>
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div>
@@ -0,0 +1,198 @@
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
factory's string-typed deserialize accepts them. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Database connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Provider</label>
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
</InputSelect>
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
</div>
<div class="col-md-8">
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="MesStaging" />
<div class="form-text">
Name of the <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var set on the driver + AdminUI hosts —
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
</div>
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
{
<div class="text-danger small mt-1">Connection string ref is required.</div>
}
</div>
</div>
</div>
</section>
@* Polling / timeouts *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Polling &amp; timeouts</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Default poll interval (s)</label>
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
</div>
<div class="col-md-3">
<label class="form-label">Command timeout (s)</label>
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max concurrent groups</label>
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
</div>
</div>
</div>
</section>
@* Value handling *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Value handling</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
</div>
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
</div>
<div class="form-text mt-0">
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
// drops the composer-owned rawTags (never authored here).
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
_form = FormModel.FromDto(dto);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static SqlDriverConfigDto? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
catch { return null; }
}
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
public sealed class FormModel
{
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
public string? ConnectionStringRef { get; set; }
public int? DefaultPollIntervalSeconds { get; set; }
public int? OperationTimeoutSeconds { get; set; }
public int? CommandTimeoutSeconds { get; set; }
public int? MaxConcurrentGroups { get; set; }
public bool NullIsBad { get; set; }
public static FormModel FromDto(SqlDriverConfigDto d) => new()
{
Provider = d.Provider,
ConnectionStringRef = d.ConnectionStringRef,
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
MaxConcurrentGroups = d.MaxConcurrentGroups,
NullIsBad = d.NullIsBad ?? false,
};
public SqlDriverConfigDto ToDto() => new()
{
Provider = Provider,
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
MaxConcurrentGroups = MaxConcurrentGroups,
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
// and rawTags are deliberately left null so they are omitted from the authored blob.
NullIsBad = NullIsBad ? true : null,
};
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
}
}
@@ -0,0 +1,441 @@
@* Sql address picker (read-only v1):
1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the
table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a
server the AdminUI can't reach, authors a tag entirely from these fields.
2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the
picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills
`table`, the value/column field, and the inferred `type`.
The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the
shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/
`type` enums are matched to the parser precisely; a mismatch would author fine and never read.
Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the
JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed
TagConfig blob nor back into the driver config — there is no code path here that persists it. *@
@implements IAsyncDisposable
@using System.Text.Json.Nodes
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Sql
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Model</label>
<select class="form-select form-select-sm" @bind="_model" @bind:after="OnManualChangedAsync">
<option value="KeyValue">KeyValue</option>
<option value="WideRow">WideRow</option>
</select>
<div class="form-text">
KeyValue: one row per key. WideRow: many columns of one selected row.
</div>
</div>
<div class="col-md-5">
<label class="form-label">Table or view</label>
<input type="text" class="form-control form-control-sm mono" placeholder="dbo.TagValues"
@bind="_table" @bind:after="OnManualChangedAsync" />
<div class="form-text">Schema-qualified, e.g. <code>dbo.TagValues</code>.</div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="OnManualChangedAsync">
<option value="">(infer)</option>
@foreach (var t in Enum.GetValues<DriverDataType>())
{
<option value="@t">@t</option>
}
</select>
<div class="form-text">Blank ⇒ inferred from the column.</div>
</div>
</div>
@if (_model == "KeyValue")
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Key column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="tag_name"
@bind="_keyColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-4">
<label class="form-label">Key value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Line1.Speed"
@bind="_keyValue" @bind:after="OnManualChangedAsync" />
<div class="form-text">Bound as a parameter — never interpolated.</div>
</div>
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="num_value"
@bind="_valueColumn" @bind:after="OnManualChangedAsync" />
</div>
</div>
}
else
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="oven_temp"
@bind="_columnName" @bind:after="OnManualChangedAsync" />
<div class="form-text">The column this tag reads from the selected row.</div>
</div>
<div class="col-md-4">
<label class="form-label">Row selector</label>
<select class="form-select form-select-sm" @bind="_selectorMode" @bind:after="OnManualChangedAsync">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select>
</div>
@if (_selectorMode == "Where")
{
<div class="col-md-2">
<label class="form-label">Where column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="station_id"
@bind="_whereColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">Where value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="7"
@bind="_whereValue" @bind:after="OnManualChangedAsync" />
</div>
}
else
{
<div class="col-md-4">
<label class="form-label">Order-by (timestamp) column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_topByTimestamp" @bind:after="OnManualChangedAsync" />
</div>
}
</div>
}
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_timestampColumn" @bind:after="OnManualChangedAsync" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div>
</div>
</div>
@if (_canOperate)
{
<hr class="my-3" />
<div class="row g-3">
<div class="col-md-12">
<label class="form-label small">Ad-hoc connection string <span class="text-muted">(session-only, never saved)</span></label>
<input type="password" class="form-control form-control-sm mono" autocomplete="off"
placeholder="Server=…;Database=…;User Id=…;Password=…"
@bind="_adhocConnectionString" />
<div class="form-text">
Leave blank to browse via the driver's <code>connectionStringRef</code> (resolved from
the AdminUI host's <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var). Paste a
literal only for a one-off browse — it is used for this session and never persisted.
</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
@onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse database
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
</div>
@if (_openError is not null)
{
<div class="text-danger small mt-2">@_openError</div>
}
@if (_token != Guid.Empty)
{
<div class="row g-3 mt-1">
<div class="col-md-7">
<label class="form-label small">Schemas → tables → columns</label>
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
SelectedNodeId="@_selectedColumnNodeId" />
</div>
<div class="col-md-5">
<label class="form-label small">Picked column</label>
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_attrsLoading)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else if (_attrsError is not null)
{
<span class="text-danger small">@_attrsError</span>
}
else if (_attrs is null)
{
<span class="text-muted small">Pick a column leaf.</span>
}
else if (_attrs.Count == 0)
{
<span class="text-muted small">Column no longer present.</span>
}
else
{
@foreach (var a in _attrs)
{
<div class="d-flex justify-content-between align-items-center py-1"
style="cursor:pointer" @onclick="@(() => ApplyAttributeAsync(a))">
<span class="mono small">@a.Name</span>
<span class="text-muted small">@a.DriverDataType · @a.SecurityClass</span>
</div>
}
}
</div>
</div>
</div>
}
}
<div class="mt-3">
<span class="text-muted small">TagConfig:</span>
<code class="mono ms-2">@(_built.Length == 0 ? "—" : _built)</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
/// <summary>Live accessor for the selected driver's <c>DriverConfig</c> JSON (carries
/// <c>connectionStringRef</c> + <c>provider</c>). Fed to <c>BrowserService.OpenAsync("Sql", …)</c>.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Fires with the picker's suggested tag display name (<c>table.column</c>) whenever a
/// column is picked from the tree, so a host editor may default the raw tag's name. Optional —
/// the composed TagConfig blob itself carries no display name (it is not a parser field).</summary>
[Parameter] public EventCallback<string> SuggestedDisplayNameChanged { get; set; }
// Manual/composed tag fields (all editable by hand; browse only prefills a subset).
private string _model = "KeyValue";
private string _table = "";
private string _keyColumn = "";
private string _keyValue = "";
private string _valueColumn = "";
private string _columnName = "";
private string _selectorMode = "Where";
private string _whereColumn = "";
private string _whereValue = "";
private string _topByTimestamp = "";
private string _timestampColumn = "";
private string _type = "";
// Browse-only, session-scoped state.
private string _adhocConnectionString = "";
private string _built = "";
private string _selectedColumnNodeId = "";
private Guid _token = Guid.Empty;
private bool _opening;
private bool _canOperate;
private string? _openError;
private bool _attrsLoading;
private string? _attrsError;
private IReadOnlyList<AttributeInfo>? _attrs;
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OpenBrowseAsync()
{
_opening = true;
_openError = null;
StateHasChanged();
try
{
// Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
finally { _opening = false; StateHasChanged(); }
}
/// <summary>
/// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one,
/// a top-level <c>connectionString</c> literal. The literal wins over any <c>connectionStringRef</c>
/// server-side (SqlDriverBrowser precedence) and is session-only.
/// </summary>
private string BuildBrowseConfig()
{
var baseJson = GetConfigJson() ?? "{}";
if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; }
JsonObject o;
try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); }
catch (System.Text.Json.JsonException) { o = new JsonObject(); }
o["connectionString"] = _adhocConnectionString;
return o.ToJsonString();
}
private async Task CloseBrowseAsync()
{
var t = _token;
_token = Guid.Empty;
_attrs = null;
_selectedColumnNodeId = "";
StateHasChanged();
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
}
/// <summary>
/// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are
/// navigation, not selection. The NodeId is decoded with <see cref="SqlBrowseNodeId.TryParse"/>
/// — never split by hand, because SQL identifiers may contain '.' and '|'.
/// </summary>
private async Task OnTreeSelectAsync(BrowseNode node)
{
if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference)
|| reference.Kind != SqlBrowseNodeKind.Column)
{
return;
}
_selectedColumnNodeId = node.NodeId;
_table = $"{reference.Schema}.{reference.Table}";
if (_model == "WideRow") { _columnName = reference.Column!; }
else { _valueColumn = reference.Column!; }
// Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified).
await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}");
_attrs = null;
_attrsLoading = true;
_attrsError = null;
StateHasChanged();
try
{
_attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default);
var picked = _attrs.Count > 0 ? _attrs[0] : null;
if (picked is not null) { PrefillType(picked); }
}
catch (Exception ex) { _attrsError = ex.Message; }
finally
{
_attrsLoading = false;
await OnChangedAsync();
}
}
/// <summary>Re-applies the side-panel column's inferred type (and value-column) on click.</summary>
private async Task ApplyAttributeAsync(AttributeInfo a)
{
if (_model == "WideRow") { _columnName = a.Name; }
else { _valueColumn = a.Name; }
PrefillType(a);
await OnChangedAsync();
}
/// <summary>Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps.</summary>
private void PrefillType(AttributeInfo a)
{
if (Enum.TryParse<DriverDataType>(a.DriverDataType, out _)) { _type = a.DriverDataType; }
}
private async Task OnManualChangedAsync() => await OnChangedAsync();
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
/// <summary>
/// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to
/// <c>SqlEquipmentTagParser</c>. Returns "" until the selected model's required fields are all
/// present, so the "Use this address" button never commits a half-blob the parser would reject.
/// <c>model</c> and <c>type</c> are written as NAME strings (the parser reads them strictly).
/// </summary>
private string Build()
{
var table = _table.Trim();
if (table.Length == 0) { return ""; }
var o = new JsonObject
{
["driver"] = "Sql",
["model"] = _model,
["table"] = table,
};
if (_model == "KeyValue")
{
var keyColumn = _keyColumn.Trim();
var valueColumn = _valueColumn.Trim();
var keyValue = _keyValue.Trim();
if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; }
o["keyColumn"] = keyColumn;
o["keyValue"] = keyValue;
o["valueColumn"] = valueColumn;
}
else // WideRow
{
var columnName = _columnName.Trim();
if (columnName.Length == 0) { return ""; }
o["columnName"] = columnName;
var rowSelector = new JsonObject();
if (_selectorMode == "Where")
{
var whereColumn = _whereColumn.Trim();
var whereValue = _whereValue.Trim();
if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; }
rowSelector["whereColumn"] = whereColumn;
rowSelector["whereValue"] = whereValue;
}
else // Newest
{
var topByTimestamp = _topByTimestamp.Trim();
if (topByTimestamp.Length == 0) { return ""; }
rowSelector["topByTimestamp"] = topByTimestamp;
}
o["rowSelector"] = rowSelector;
}
var timestampColumn = _timestampColumn.Trim();
if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; }
if (_type.Length > 0) { o["type"] = _type; }
// v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side).
o["writable"] = false;
return o.ToJsonString();
}
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow database.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
}
@@ -68,6 +68,7 @@
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
];
@@ -0,0 +1,211 @@
@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by
TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Model</label>
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
<option value="@SqlTagModel.KeyValue">KeyValue</option>
<option value="@SqlTagModel.WideRow">WideRow</option>
</select></div>
<div class="col-md-5"><label class="form-label">Table or view</label>
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Type override</label>
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(infer from column)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@if (_m.Model == SqlTagModel.KeyValue)
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Key column</label>
<input class="form-control form-control-sm mono" value="@_m.KeyColumn" placeholder="tag_name" @onchange="@(e => Update(() => _m.KeyColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-4"><label class="form-label">Key value</label>
<input class="form-control form-control-sm mono" value="@_m.KeyValue" placeholder="Line1.Speed" @onchange="@(e => Update(() => _m.KeyValue = e.Value?.ToString()))" />
<div class="form-text">Bound as a parameter — never interpolated.</div></div>
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ValueColumn" placeholder="num_value" @onchange="@(e => Update(() => _m.ValueColumn = NullIfBlank(e.Value)))" /></div>
</div>
}
else
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ColumnName" placeholder="oven_temp" @onchange="@(e => Update(() => _m.ColumnName = NullIfBlank(e.Value)))" />
<div class="form-text">The column this tag reads from the selected row.</div></div>
<div class="col-md-4"><label class="form-label">Row selector</label>
<select class="form-select form-select-sm" value="@_selectorMode" @onchange="@(e => OnSelectorModeChanged(e.Value?.ToString()))">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select></div>
@if (_selectorMode == "Where")
{
<div class="col-md-2"><label class="form-label">Where column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereColumn" placeholder="station_id" @onchange="@(e => Update(() => _m.RowSelector.WhereColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">Where value</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereValue" placeholder="7" @onchange="@(e => Update(() => _m.RowSelector.WhereValue = e.Value?.ToString()))" /></div>
}
else
{
<div class="col-md-4"><label class="form-label">Order-by (timestamp) column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.TopByTimestamp" placeholder="sample_ts" @onchange="@(e => Update(() => _m.RowSelector.TopByTimestamp = NullIfBlank(e.Value)))" /></div>
}
</div>
}
<div class="row g-2 mt-1">
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div></div>
<div class="col-12 mt-1">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
</div>
</div>
@if (_showPicker)
{
<DriverTagPicker @bind-Visible="_showPicker"
Title="Sql tag builder"
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
<SqlAddressPickerBody CurrentAddress="@_pickerAddress"
CurrentAddressChanged="@((s) => _pickerAddress = s)"
GetConfigJson="@GetDriverConfigJson" />
</DriverTagPicker>
}
@code {
/// <summary>The tag's current TagConfig JSON.</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Raised with the updated TagConfig JSON when the model changes.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private SqlTagConfigModel _m = new();
private string? _lastConfigJson;
private bool _showPicker;
private string _pickerAddress = "";
// Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
// carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in
// the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state
// the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously
// says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where".
private string _selectorMode = "Where";
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server
// live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors).
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = SqlTagConfigModel.FromJson(ConfigJson);
DeriveSelectorMode();
}
// Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves
// the operator's current choice untouched when both selector fields are blank (an in-progress edit).
private void DeriveSelectorMode()
{
if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
{
_selectorMode = "Newest";
}
else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
{
_selectorMode = "Where";
}
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by
// NAME (never an ordinal cast), matching the model's strict name-string serialisation.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip
// is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
{
var s = v?.ToString();
if (string.IsNullOrEmpty(s)) { return null; }
return Enum.TryParse<TEnum>(s, out var r) ? r : null;
}
// Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the
// key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value
// as present-and-empty, distinct from absent, so those keep e.Value verbatim.)
private static string? NullIfBlank(object? v)
{
var s = v?.ToString();
return string.IsNullOrWhiteSpace(s) ? null : s;
}
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
// The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of
// the mode being left so the composed blob carries exactly one selector (a stale where-pair would win
// over topByTimestamp at read time, silently overriding the operator's choice).
private async Task OnSelectorModeChanged(string? mode)
{
_selectorMode = mode == "Newest" ? "Newest" : "Where";
await Update(() =>
{
if (_selectorMode == "Newest")
{
_m.RowSelector.WhereColumn = null;
_m.RowSelector.WhereValue = null;
}
else
{
_m.RowSelector.TopByTimestamp = null;
}
});
}
// The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the
// other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working
// model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that
// the picker never authors.
private async Task OnAddressPicked(string blob)
{
if (string.IsNullOrWhiteSpace(blob)) { return; }
var picked = SqlTagConfigModel.FromJson(blob);
await Update(() =>
{
_m.Model = picked.Model;
_m.Table = picked.Table;
_m.KeyColumn = picked.KeyColumn;
_m.KeyValue = picked.KeyValue;
_m.ValueColumn = picked.ValueColumn;
_m.ColumnName = picked.ColumnName;
_m.TimestampColumn = picked.TimestampColumn;
_m.Type = picked.Type;
_m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn;
_m.RowSelector.WhereValue = picked.RowSelector.WhereValue;
_m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp;
});
DeriveSelectorMode();
}
}
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
// ctor param has a production default. The universal-browser DI line is deliberately NOT added
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
@@ -0,0 +1,212 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column
/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the
/// authoritative runtime reader <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
/// (top-level and inside <c>rowSelector</c>) across a load→save so a future field survives an edit.
/// </summary>
public sealed class SqlTagConfigModel
{
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
public string? Table { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
public string? KeyColumn { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
public string? KeyValue { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
public string? ValueColumn { get; set; }
/// <summary>Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.</summary>
public string? TimestampColumn { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</summary>
public string? ColumnName { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
public SqlRowSelectorModel RowSelector { get; set; } = new();
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
/// driver infers the type from the result-set column metadata.</summary>
public DriverDataType? Type { get; set; }
private JsonObject _bag = new();
// The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's
// strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate).
private string? _invalidModelRaw;
private string? _invalidTypeRaw;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="SqlTagConfigModel"/>.</returns>
public static SqlTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var (model, invalidModel) = ReadEnumStrict<SqlTagModel>(o, "model");
var (type, invalidType) = ReadEnumStrict<DriverDataType>(o, "type");
var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null;
return new SqlTagConfigModel
{
Model = model ?? SqlTagModel.KeyValue,
Table = TagConfigJson.GetString(o, "table"),
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
KeyValue = TagConfigJson.GetString(o, "keyValue"),
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
Type = type,
_bag = o,
_invalidModelRaw = invalidModel,
_invalidTypeRaw = invalidType,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as
/// their name strings) over the preserved key bag. The nested <c>rowSelector</c> object is rebuilt from
/// <see cref="RowSelector"/> (preserving its own unknown keys) and omitted entirely when empty.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "model", Model);
TagConfigJson.Set(_bag, "table", Table);
TagConfigJson.Set(_bag, "keyColumn", KeyColumn);
TagConfigJson.Set(_bag, "keyValue", KeyValue);
TagConfigJson.Set(_bag, "valueColumn", ValueColumn);
TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn);
TagConfigJson.Set(_bag, "columnName", ColumnName);
TagConfigJson.Set(_bag, "type", Type);
var selector = RowSelector.ToJsonObject();
if (selector is null) { _bag.Remove("rowSelector"); }
else { _bag["rowSelector"] = selector; }
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the model against the exact accept/reject boundary of
/// <see cref="SqlEquipmentTagParser.TryParse"/>. Returns an error message, or <c>null</c> when valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
// Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag.
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
// Query is design §5.4 / P3 — the parser rejects it; the editor must too.
if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; }
if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; }
switch (Model)
{
case SqlTagModel.KeyValue:
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
return null;
case SqlTagModel.WideRow:
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
// A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but
// must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`.
var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null;
if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp))
{
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
}
return null;
default:
return "model is not a supported Sql tag model.";
}
}
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it.
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
where TEnum : struct, Enum
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var s))
{
return (null, null);
}
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
}
}
/// <summary>
/// Typed working model for a <see cref="SqlTagModel.WideRow"/> tag's nested <c>rowSelector</c> object.
/// Holds a where-pair (<see cref="WhereColumn"/>/<see cref="WhereValue"/>) or a newest-by-timestamp
/// selector (<see cref="TopByTimestamp"/>). Preserves unknown keys inside the selector across load→save.
/// </summary>
public sealed class SqlRowSelectorModel
{
/// <summary>The column that picks the row (matched against <see cref="WhereValue"/>).</summary>
public string? WhereColumn { get; set; }
/// <summary>The value <see cref="WhereColumn"/> is matched against (bound as a parameter at read time).
/// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring
/// the parser's scalar read.</summary>
public string? WhereValue { get; set; }
/// <summary>Newest-row selector: the timestamp column to order by (used when no where-pair is authored).</summary>
public string? TopByTimestamp { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a selector model from the nested <c>rowSelector</c> JSON object (or a fresh empty model
/// when the object is absent), retaining any unknown keys.</summary>
/// <param name="o">The nested <c>rowSelector</c> object, or <c>null</c> when absent.</param>
/// <returns>The populated <see cref="SqlRowSelectorModel"/>.</returns>
public static SqlRowSelectorModel FromJson(JsonObject? o)
{
// Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson).
var bag = o?.DeepClone().AsObject() ?? new JsonObject();
return new SqlRowSelectorModel
{
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
WhereValue = ReadScalarText(bag, "whereValue"),
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
_bag = bag,
};
}
/// <summary>Rebuilds the nested <c>rowSelector</c> JSON object from the exposed fields over the preserved
/// key bag, or returns <c>null</c> when the selector carries nothing (so the parent omits the key).</summary>
/// <returns>The <c>rowSelector</c> <see cref="JsonObject"/>, or <c>null</c> when empty.</returns>
public JsonObject? ToJsonObject()
{
var o = _bag.DeepClone().AsObject();
TagConfigJson.Set(o, "whereColumn", WhereColumn);
TagConfigJson.Set(o, "whereValue", WhereValue);
TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp);
return o.Count == 0 ? null : o;
}
// Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw
// token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar).
private static string? ReadScalarText(JsonObject o, string name)
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
}
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -21,6 +22,10 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -23,6 +24,8 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -39,6 +39,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
</ItemGroup>
</Project>
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -141,15 +142,39 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
/// </remarks>
/// <param name="members">The current cluster members, in any order.</param>
/// <returns>The oldest Up driver member's address, or <c>null</c> when there is none.</returns>
public static Address? SelectDriverPrimary(IEnumerable<Member> members)
public static Address? SelectDriverPrimary(IEnumerable<Member> members) =>
SelectOldestUpMemberOfRole(members, DriverRole);
/// <summary>
/// Selects the oldest Up member carrying <paramref name="role"/> — the node that
/// <c>ClusterSingletonManager</c> would place a role-scoped singleton on.
/// </summary>
/// <param name="members">The current cluster members, in any order.</param>
/// <param name="role">The cluster role to scope the selection to.</param>
/// <returns>The oldest Up member's address for that role, or <c>null</c> when there is none.</returns>
/// <remarks>
/// <para>
/// The age-ordering rationale in <see cref="SelectDriverPrimary"/> applies verbatim to any
/// role: oldest, never <c>RoleLeader</c>. The rule itself lives in the shared
/// <see cref="ClusterActiveNode"/> (<c>ZB.MOM.WW.Health.Akka</c> 0.3.0) so it has exactly
/// one implementation family-wide — this method is the control plane's entry point into it.
/// </para>
/// <para>
/// Delegating rather than re-implementing is what keeps the redundancy snapshot and the
/// <c>/health/active</c> tier structurally in agreement. They answer for different
/// consumers — the OPC UA <c>ServiceLevel</c> 250/240 split reads this election, while an
/// orchestrator routes admin traffic by the health tier — so a divergence would advertise
/// one node as authoritative while gating the data plane on another. Both apps in the
/// family previously kept private copies of this rule, and both got it wrong in a
/// different way.
/// </para>
/// </remarks>
public static Address? SelectOldestUpMemberOfRole(IEnumerable<Member> members, string role)
{
ArgumentNullException.ThrowIfNull(members);
ArgumentException.ThrowIfNullOrWhiteSpace(role);
return members
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole))
.OrderBy(m => m, Member.AgeOrdering)
.FirstOrDefault()
?.Address;
return ClusterActiveNode.OldestUpMember(members, role)?.Address;
}
private IReadOnlyList<NodeRedundancyState> BuildSnapshot()
@@ -189,9 +189,9 @@ public static class ServiceCollectionExtensions
/// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub.
/// </remarks>
public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton(
this AkkaConfigurationBuilder builder, IClusterRoleInfo roleInfo)
this AkkaConfigurationBuilder builder, AkkaClusterOptions clusterOptions)
{
var singletonOptions = BuildClusterRedundancySingletonOptions(roleInfo);
var singletonOptions = BuildClusterRedundancySingletonOptions(clusterOptions);
builder.WithSingleton<RedundancyStateActorKey>(
RedundancyStateSingletonName,
@@ -216,13 +216,23 @@ public static class ServiceCollectionExtensions
/// (b) on a genuinely split 2-node mesh the <c>driver</c> role is already pair-local — the mesh IS
/// the pair. So the fallback is correct in both worlds and costs no availability.
/// </remarks>
/// <param name="roleInfo">The local node's cluster-role view.</param>
/// <param name="clusterOptions">The node's own cluster configuration (roles), read without the
/// ActorSystem so this is safe to call inside the Akka configurator lambda.</param>
/// <returns>Singleton options scoped to the node's cluster role, or the <c>driver</c> role.</returns>
public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(IClusterRoleInfo roleInfo)
public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(AkkaClusterOptions clusterOptions)
{
ArgumentNullException.ThrowIfNull(roleInfo);
ArgumentNullException.ThrowIfNull(clusterOptions);
return new ClusterSingletonOptions { Role = roleInfo.ClusterRole ?? RoleParser.Driver };
// Derive the cluster-{ClusterId} scope from the node's OWN configured roles — NOT from
// IClusterRoleInfo. IClusterRoleInfo's implementation depends on the ActorSystem (it is a live
// Cluster.State view), and this runs inside the AddAkka configurator lambda WHILE the
// ActorSystem is being built; resolving IClusterRoleInfo there recurses back into ActorSystem
// construction and stack-overflows the host at boot (the Phase 6 live gate caught exactly this
// on the fused central node). AkkaClusterOptions is pure configuration, available before the
// cluster forms — the same source ClusterRoleInfo.ClusterRole itself derives from — so the
// scope is identical. First cluster role wins, in configuration order (matching ClusterRoleInfo).
var clusterRole = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole);
return new ClusterSingletonOptions { Role = clusterRole ?? RoleParser.Driver };
}
}
@@ -19,6 +19,14 @@
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
<PackageReference Include="Grpc.Net.Client"/>
<PackageReference Include="ZB.MOM.WW.Audit"/>
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
plane and the /health/active tier must give the SAME answer for which node is in charge,
or the tier reports one node active while the Primary-gated data plane runs on another;
delegating here is what makes that structural rather than a convention. It lives in
Health.Akka because that is the family's Akka-cluster utility package (it already hosts
AkkaClusterStatusPolicy and an endpoint gate); a dedicated cluster package would be a
better home if a third such primitive ever appears. -->
<PackageReference Include="ZB.MOM.WW.Health.Akka"/>
</ItemGroup>
<ItemGroup>
@@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe;
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
using SqlProbe = Driver.Sql.SqlDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
return services;
}
@@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
}
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
@@ -17,7 +18,7 @@ public static class HealthEndpoints
{
/// <summary>
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
/// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh
/// ready+active; cluster-primary on active only. The configdb probe is admin-only (per-cluster mesh
/// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff <paramref name="hasAdmin"/>.
/// </summary>
/// <param name="services">The service collection to register the health checks on.</param>
@@ -53,11 +54,33 @@ public static class HealthEndpoints
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
// "Is this node in charge of its mesh?" — Healthy on the one node that owns the active
// work for its Cluster, Unhealthy (503) on its partner, so Traefik pins the AdminUI to the
// node actually hosting the cluster singletons.
//
// Role preference admin-then-driver: a fused central node answers for `admin` (the role
// the singletons and the AdminUI are pinned to), while a driver-only site node answers for
// `driver` — which is exactly RedundancyStateActor.SelectDriverPrimary, the election behind
// IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Both now route through the
// shared ClusterActiveNode, so the tier and the ServiceLevel cannot disagree.
//
// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's ClusterState
// contains only its own application Cluster, so "oldest Up member" is already "oldest Up
// member of this Cluster" — exactly one node answers 200 per Cluster, by construction.
//
// Before Health 0.3.0 this was ActiveNodeHealthCheck(role: "admin"), which answered a
// different question and got it wrong twice: it returned Healthy for any node lacking the
// admin role, so every driver-only site node called itself active, and it selected by
// RoleLeader (lowest address) rather than by age, which is not where Akka places
// singletons. See lmxopcua#494.
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
"admin-leader",
"cluster-primary",
failureStatus: null,
tags: new[] { ZbHealthTags.Active },
args: "admin")
args: new ActiveNodeHealthCheckOptions
{
RolePreference = new[] { RoleParser.Admin, RoleParser.Driver },
})
// Registered on every node regardless of role (unlike the admin-only configdb probe above):
// the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
// (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
+17 -3
View File
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Serilog;
using ZB.MOM.WW.LocalDb.Replication;
using Serilog.Events;
@@ -380,12 +381,25 @@ builder.Services.AddAkka("otopcua", (ab, sp) =>
// back to the driver role for legacy single-mesh / not-yet-migrated nodes (already pair-local
// on a split mesh, since the mesh IS the pair). Runs on the fused central node too (it is
// hasDriver), where it scopes to cluster-MAIN — exactly one redundancy singleton per node,
// since the admin branch above no longer registers one. IClusterRoleInfo is registered by
// AddOtOpcUaCluster and resolved lazily here.
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>());
// since the admin branch above no longer registers one. Pass AkkaClusterOptions (pure config),
// NOT IClusterRoleInfo: this lambda runs WHILE the ActorSystem is being built, and
// IClusterRoleInfo depends on the ActorSystem — resolving it here recurses into ActorSystem
// construction and stack-overflows the host at boot. The singleton scope only needs the node's
// configured cluster role, which AkkaClusterOptions carries.
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
}
});
// Simultaneous-cold-start split-brain guard (dark switch Cluster:BootstrapGuard:Enabled, default off).
// Registered AFTER AddAkka so its StartAsync runs once Akka's hosted service has built (and, when the
// guard is off, already auto-joined) the ActorSystem. When on, the node started with no config seeds
// (BuildClusterOptions) and this coordinator issues the single reachability-gated JoinSeedNodes. It
// no-ops when the guard is off, so registering it unconditionally is safe.
builder.Services.AddHostedService(sp => new ClusterBootstrapCoordinator(
() => sp.GetRequiredService<ActorSystem>(),
sp.GetRequiredService<IOptions<AkkaClusterOptions>>(),
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
// Down-if-alone recovery watchdog (#459). Registered AFTER AddAkka so it starts after Akka's own
// hosted service has built the ActorSystem; it resolves the system lazily (never at construction) so
// it can't race startup. On an unexpected SBR self-down it stops the host so the service supervisor
@@ -76,6 +76,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
</ItemGroup>
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
ReassertConditionNodes();
}
/// <summary>
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
/// its condition node, right after a (re)load.
///
/// <para>
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
/// re-derives Active from the predicate, so a still-active alarm ends the reload
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
/// client until its next real transition, which may never come. Same shape as the VirtualTag
/// redeploy-reset the <c>ReassertValue</c> fix closed.
/// </para>
///
/// <para>
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
/// </para>
///
/// <para>
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
/// already queued ahead of these writes: the node exists by the time they are handled.
/// </para>
///
/// <para>
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
/// matches what the materialise just built, so writing it would be a pure no-op on state —
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
/// display name; a projection carries the resolved template). Skipping them keeps a
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
/// at zero on the overwhelmingly common all-normal deploy.
/// </para>
///
/// <para>
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
/// like every other node write here: the Secondary keeps its address space warm for failover.
/// </para>
/// </summary>
private void ReassertConditionNodes()
{
var reasserted = 0;
foreach (var projection in _engine.GetProjections())
{
if (IsInNoEventPosition(projection.Condition))
{
continue;
}
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: projection.AlarmId,
State: ToSnapshot(projection),
// The instant the state we are re-asserting actually came about — NOT the deploy time.
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
// active, not when the address space happened to be rebuilt.
TimestampUtc: projection.Condition.LastTransitionUtc,
Realm: AddressSpaceRealm.Uns));
reasserted++;
}
if (reasserted > 0)
{
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
}
}
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
/// <param name="condition">The engine's current condition state.</param>
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
private static bool IsInNoEventPosition(AlarmConditionState condition)
=> condition.Enabled == AlarmEnabledState.Enabled
&& condition.Active == AlarmActiveState.Inactive
&& condition.Acked == AlarmAckedState.Acknowledged
&& condition.Confirmed == AlarmConfirmedState.Confirmed
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
{
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
Active: e.Condition.Active == AlarmActiveState.Active,
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(e.Condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(e.Severity),
Message: e.Message,
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
=> ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
/// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
/// to restore a condition node after a rebuild, never an emission. Shares
/// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
/// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
/// <param name="p">The engine projection to map.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
/// paths.</summary>
/// <param name="condition">The Part 9 condition state.</param>
/// <param name="severity">The engine's coarse severity bucket.</param>
/// <param name="message">The resolved condition message.</param>
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
Active: condition.Active == AlarmActiveState.Active,
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(severity),
Message: message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(e.WorstInputStatusCode));
Quality: QualityFromStatus(worstInputStatusCode));
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -174,4 +174,44 @@ public sealed class AkkaClusterOptionsValidatorTests
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>The bootstrap-guard timing knobs default to positive values and pass when enabled.</summary>
[Fact]
public void Bootstrap_guard_defaults_pass()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// A zero <c>PartnerProbeSeconds</c> silently degrades the guard to "never wait, form alone
/// immediately" — re-opening the split. It must fail the host at boot, not run degraded.
/// </summary>
[Fact]
public void Bootstrap_guard_with_non_positive_probe_seconds_fails()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeSeconds = 0 };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("PartnerProbeSeconds");
}
/// <summary>The timing knobs are NOT validated when the guard is off — a disabled guard is inert.</summary>
[Fact]
public void Bootstrap_guard_disabled_does_not_validate_timings()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = false, PartnerProbeSeconds = 0 };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
}
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard
/// (<see cref="ClusterBootstrapCoordinator"/> + <see cref="ClusterBootstrapGuard"/>), started through
/// the SAME wiring as production (<see cref="ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap"/>
/// with <c>Cluster:BootstrapGuard:Enabled</c> + the coordinator hosted service). This subsystem has a
/// history of bugs invisible to pure unit tests, so the load-bearing correctness properties are
/// asserted against running nodes, mirroring <see cref="SelfFirstSeedBootstrapTests"/>.
/// </summary>
/// <remarks>
/// Ephemeral loopback ports are 5-digit, so numeric order equals the guard's ordinal "host:port"
/// string order — <c>Min(port)</c> is the founder, <c>Max(port)</c> is the higher (probing) node.
/// </remarks>
public sealed class ClusterBootstrapCoordinatorTests
{
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// Starts a node with the bootstrap guard ENABLED, wired exactly as Program.cs does: empty Akka
/// seed list (via <c>BuildClusterOptions</c>) plus the coordinator hosted service that issues the
/// reachability-gated <c>JoinSeedNodes</c>. Seeds are listed self-first (as every shipped config
/// is); the guard, not the order, decides founder vs. joiner.
/// </summary>
private static async Task<IHost> StartGuardedNodeAsync(
string systemName, int selfPort, int peerPort, int probeSeconds = 4)
{
var self = $"akka.tcp://{systemName}@127.0.0.1:{selfPort}";
var peer = $"akka.tcp://{systemName}@127.0.0.1:{peerPort}";
var options = new AkkaClusterOptions
{
SystemName = systemName,
Hostname = "127.0.0.1",
PublicHostname = "127.0.0.1",
Port = selfPort,
Roles = new[] { "driver" },
SeedNodes = new[] { self, peer },
BootstrapGuard = new ClusterBootstrapGuardOptions
{
Enabled = true,
PartnerProbeSeconds = probeSeconds,
PartnerProbeIntervalMs = 250,
ProbeConnectTimeoutMs = 500,
},
};
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
services.AddAkka(systemName, (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp));
// Mirror Program.cs — the coordinator is what drives the guarded join.
services.AddHostedService(sp => new ClusterBootstrapCoordinator(
() => sp.GetRequiredService<ActorSystem>(),
sp.GetRequiredService<IOptions<AkkaClusterOptions>>(),
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
});
var host = builder.Build();
await host.StartAsync();
return host;
}
private static async Task<bool> WaitForUpMembersAsync(IHost host, int expected, TimeSpan timeout)
{
var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService<ActorSystem>());
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected) return true;
await Task.Delay(200);
}
return cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected;
}
private static async Task StopAsync(IHost host)
{
try { await host.StopAsync(); }
catch (Exception) { /* teardown only */ }
host.Dispose();
}
/// <summary>
/// The FOUNDER (lower address) with the guard on forms a cluster alone when its partner is dead —
/// exactly as un-guarded self-first does. The guard must not regress the preferred founder.
/// </summary>
[Fact]
public async Task Founder_forms_alone_when_partner_is_dead()
{
var low = FreePort();
var high = FreePort();
var founderPort = Math.Min(low, high);
var deadPeerPort = Math.Max(low, high); // nothing listening
var host = await StartGuardedNodeAsync("otopcua-guard-1", founderPort, deadPeerPort);
try
{
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the founder (lower address) must self-form immediately when its partner is down");
}
finally { await StopAsync(host); }
}
/// <summary>
/// THE LOAD-BEARING CASE. The HIGHER node with the guard on must still cold-start ALONE when its
/// partner is genuinely dead: it probes the (dead) founder, times out, and falls back to self-first.
/// If the guard's peer-first logic were wrong, this node would hang in JoinSeedNodeProcess forever —
/// the very regression a naive "always let the lower node found" rule would introduce.
/// </summary>
[Fact]
public async Task Higher_node_forms_alone_when_partner_is_dead_after_probing()
{
var low = FreePort();
var high = FreePort();
var higherPort = Math.Max(low, high);
var deadFounderPort = Math.Min(low, high); // the founder is dead
var host = await StartGuardedNodeAsync("otopcua-guard-2", higherPort, deadFounderPort, probeSeconds: 3);
try
{
// Must come Up AFTER the probe window (~3 s) expires and it falls back to self-first.
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the higher node must fall back to self-first and form alone once the dead partner never answers the probe");
}
finally { await StopAsync(host); }
}
/// <summary>
/// FALSIFIABILITY CONTROL for the test above: the higher node does NOT form alone during the probe
/// window — it is genuinely waiting/probing, not self-forming immediately (which would mean the
/// guard is inert). If this ever starts seeing a member before the window, the probe is not gating.
/// </summary>
[Fact]
public async Task Higher_node_does_not_form_before_the_probe_window_expires()
{
var low = FreePort();
var high = FreePort();
var higherPort = Math.Max(low, high);
var deadFounderPort = Math.Min(low, high);
var host = await StartGuardedNodeAsync("otopcua-guard-3", higherPort, deadFounderPort, probeSeconds: 10);
try
{
// Well within the 10 s probe window: still no cluster, because it is probing the dead founder.
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(3)))
.ShouldBeFalse("the higher node must probe for the founder, not self-form immediately");
}
finally { await StopAsync(host); }
}
/// <summary>
/// The higher node JOINS a live founder rather than forming a second cluster: start the founder,
/// then the higher node — its probe finds the founder reachable, it commits peer-first, and the
/// pair converges to ONE cluster of two.
/// </summary>
[Fact]
public async Task Higher_node_joins_a_live_founder()
{
const string systemName = "otopcua-guard-4";
var low = FreePort();
var high = FreePort();
var founderPort = Math.Min(low, high);
var higherPort = Math.Max(low, high);
var founder = await StartGuardedNodeAsync(systemName, founderPort, higherPort);
IHost? higher = null;
try
{
(await WaitForUpMembersAsync(founder, 1, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the founder must be Up before the higher node probes it");
higher = await StartGuardedNodeAsync(systemName, higherPort, founderPort);
(await WaitForUpMembersAsync(higher, 2, TimeSpan.FromSeconds(60)))
.ShouldBeTrue("the higher node must join the founder, forming one cluster of two");
(await WaitForUpMembersAsync(founder, 2, TimeSpan.FromSeconds(60)))
.ShouldBeTrue("the founder must see the higher node as a member of ITS cluster");
}
finally
{
if (higher is not null) await StopAsync(higher);
await StopAsync(founder);
}
}
}
@@ -0,0 +1,137 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Unit tests for <see cref="ClusterBootstrapGuard"/> — the pure decision core of the
/// simultaneous-cold-start split-brain guard. The runtime probe + JoinSeedNodes wiring lives in
/// <c>ClusterBootstrapCoordinator</c> and is covered by the live gate.
/// </summary>
public sealed class ClusterBootstrapGuardTests
{
private const string A = "akka.tcp://otopcua@site-a-1:4053";
private const string B = "akka.tcp://otopcua@site-a-2:4053";
[Fact]
public void Lower_address_node_is_the_founder_and_needs_no_probe()
{
// site-a-1 < site-a-2, so on site-a-1 the guard makes it the founder.
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053);
role.Applies.ShouldBeTrue();
role.IsFounder.ShouldBeTrue();
role.SelfFirstOrder.ShouldBe(new[] { A, B });
}
[Fact]
public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing()
{
// On site-a-2, the partner is site-a-1 (the lower/founder).
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
role.Applies.ShouldBeTrue();
role.IsFounder.ShouldBeFalse();
role.PartnerHost.ShouldBe("site-a-1");
role.PartnerPort.ShouldBe(4053);
}
[Fact]
public void Tie_break_is_symmetric_regardless_of_seed_order()
{
// Both nodes must agree on who founds no matter how each lists its seeds.
var onLower = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053);
var onHigher = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
onLower.IsFounder.ShouldBeTrue();
onHigher.IsFounder.ShouldBeFalse();
// Exactly one founder.
(onLower.IsFounder ^ onHigher.IsFounder).ShouldBeTrue();
}
[Fact]
public void Higher_node_joins_the_founder_when_partner_is_reachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
// Reachable ⇒ peer-first ⇒ JoinSeedNodeProcess ⇒ joins the founder, never self-forms.
ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true)
.ShouldBe(new[] { A, B }); // partner (site-a-1) first
}
[Fact]
public void Higher_node_forms_alone_when_partner_is_unreachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
// Unreachable after the window ⇒ partner is dead ⇒ self-first ⇒ cold-start-alone preserved.
ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false)
.ShouldBe(new[] { B, A }); // self (site-a-2) first
}
[Fact]
public void Guard_does_not_apply_to_a_single_seed_node()
{
// A driver-only site node seeded solely by central-1 is not a pair seed — guard is inert.
var role = ClusterBootstrapGuard.Analyze(new[] { "akka.tcp://otopcua@central-1:4053" }, "site-x", 4053);
role.Applies.ShouldBeFalse();
}
[Fact]
public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "central-1", 4053);
role.Applies.ShouldBeFalse();
}
[Fact]
public void Guard_does_not_apply_to_three_or_more_seeds()
{
var role = ClusterBootstrapGuard.Analyze(
new[] { A, B, "akka.tcp://otopcua@site-a-3:4053" }, "site-a-1", 4053);
role.Applies.ShouldBeFalse();
}
[Fact]
public void Guard_does_not_apply_when_a_seed_is_malformed()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, "not-a-seed" }, "site-a-1", 4053);
role.Applies.ShouldBeFalse();
}
[Theory]
[InlineData("akka.tcp://otopcua@site-a-1:4053", "site-a-1", 4053)]
[InlineData("akka.tcp://otopcua@10.0.0.5:4053/", "10.0.0.5", 4053)]
[InlineData(" akka.tcp://otopcua@host.lan:1234 ", "host.lan", 1234)]
public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort)
{
ClusterBootstrapGuard.TryParse(seed, out var host, out var port).ShouldBeTrue();
host.ShouldBe(expectedHost);
port.ShouldBe(expectedPort);
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("akka.tcp://otopcua@host")] // no port
public void TryParse_rejects_malformed_seeds(string seed)
{
ClusterBootstrapGuard.TryParse(seed, out _, out _).ShouldBeFalse();
}
[Fact]
public void Port_participates_in_the_tie_break_when_hosts_are_equal()
{
// Shared-host loopback pair (test rig): same host, different ports — port breaks the tie.
const string low = "akka.tcp://otopcua@127.0.0.1:4053";
const string high = "akka.tcp://otopcua@127.0.0.1:4054";
ClusterBootstrapGuard.Analyze(new[] { low, high }, "127.0.0.1", 4053).IsFounder.ShouldBeTrue();
ClusterBootstrapGuard.Analyze(new[] { high, low }, "127.0.0.1", 4054).IsFounder.ShouldBeFalse();
}
}
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
/// <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
private const string Code = "SqlConnectionStringPersisted";
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
private const string LeakedConfig =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private static DriverInstance SqlDriver(string config) => new()
{
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
DriverType = "Sql", DriverConfig = config,
};
private static Device Device(string driverInstanceId, string config) => new()
{
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
};
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = [driver],
Devices = device is null ? [] : [device],
};
[Fact]
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
/// problem without repeating the credential it is refusing to store.
/// </summary>
[Fact]
public void Error_message_does_not_echo_the_credential()
{
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
error.Message.ShouldNotContain("hunter2");
error.Message.ShouldNotContain("Server=sql,1433");
error.Message.ShouldContain("connectionStringRef");
}
/// <summary>
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
/// </summary>
[Theory]
[InlineData("ConnectionString")]
[InlineData("CONNECTIONSTRING")]
[InlineData("connectionstring")]
public void Key_match_is_case_insensitive(string key)
{
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
DraftValidator.Validate(Draft(SqlDriver(config)))
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
/// there is the identical leak and must fail the same way.
/// </summary>
[Fact]
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
{
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
}
[Fact]
public void A_properly_authored_connectionStringRef_passes()
{
var draft = Draft(
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
Device("di-sql", """{"pollIntervalMs":1000}"""));
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
/// be a regression, not defence in depth.
/// </summary>
[Fact]
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
{
var modbus = new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = LeakedConfig,
};
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
/// </summary>
[Fact]
public void A_device_under_a_non_Sql_driver_is_not_flagged()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [Device("di-mb", LeakedConfig)],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
/// rule's job, and a parse failure here would take down every other check in the same pass.
/// </summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("\"connectionString\"")]
[InlineData("{\"provider\":\"SqlServer\"")]
public void Malformed_config_neither_throws_nor_flags(string config)
{
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
.ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
/// positive on, say, a tag blob that happens to describe a connection string.
/// </summary>
[Fact]
public void A_nested_connectionString_is_not_flagged()
{
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
}
}
@@ -0,0 +1,160 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards every driver's hard-coded OPC UA status-code constant against
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
/// <em>name</em> disagrees with its <em>value</em> fails here.
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
/// Gitea #497 found in six places across four drivers.</para>
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
/// </summary>
public sealed class StatusCodeParityTests
{
/// <summary>
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
/// that does not start with one of them is not a status constant.
/// </summary>
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
/// <summary>
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
/// </summary>
private static readonly string[] NamePrefixesToStrip = ["Status"];
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
typeof(Opc.Ua.StatusCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
/// <summary>
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
/// </summary>
/// <remarks>
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
/// modifiers say nothing about whether a value reaches an OPC UA client.
/// </remarks>
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
{
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type?[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
foreach (var type in types)
{
if (type is null) continue;
var fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
foreach (var field in fields)
{
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
var sdkName = StripKnownPrefix(field.Name);
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
data.Add(
asm.GetName().Name!,
type.FullName ?? type.Name,
field.Name,
sdkName,
(uint)field.GetRawConstantValue()!);
}
}
}
return data;
}
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
private static string StripKnownPrefix(string fieldName)
{
foreach (var prefix in NamePrefixesToStrip)
{
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
return fieldName[prefix.Length..];
}
return fieldName;
}
/// <summary>
/// A driver's status constant must carry the value the SDK gives the code it is named after.
/// </summary>
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
/// <param name="type">The declaring type's full name (failure-message context).</param>
/// <param name="field">The constant's name as declared.</param>
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
/// <param name="declared">The value the driver declares.</param>
[Theory]
[MemberData(nameof(DeclaredStatusConstants))]
public void Driver_status_constant_matches_the_pinned_SDK(
string assembly, string type, string field, string sdkName, uint declared)
{
SdkStatusCodes.ShouldContainKey(sdkName,
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
"not start with Good/Uncertain/Bad.");
var expected = SdkStatusCodes[sdkName];
declared.ShouldBe(expected,
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
$"is 0x{expected:X8}" +
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
: ". The declared value is not any OPC UA status code."));
}
/// <summary>
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
/// </summary>
[Fact]
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
{
var found = DeclaredStatusConstants();
found.Count.ShouldBeGreaterThan(20,
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
"or the naming convention changed");
var assemblies = found
.Select(row => row.Data.Item1)
.Distinct(StringComparer.Ordinal)
.ToArray();
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
}
@@ -12,6 +12,8 @@
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -34,6 +36,11 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
puts it in StatusCodeParityTests' scope. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,180 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
/// transition (which for a still-active alarm with static dependencies may never come).
/// </summary>
/// <remarks>
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
/// report the state, and reading it does not fire an emission.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmProjectionTests
{
private static ScriptedAlarmDefinition Alarm(
string id = "HighTemp",
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
string msg = "Temp is {Temp}",
AlarmSeverity sev = AlarmSeverity.High) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate);
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
private static AlarmConditionState PersistedActive(
string alarmId = "HighTemp",
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
AlarmEnabledState.Enabled,
AlarmActiveState.Active,
acked,
AlarmConfirmedState.Confirmed,
ShelvingState.Unshelved,
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
[Fact]
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
{
var up = new FakeUpstream();
up.Set("Temp", 150); // predicate still true — no transition to report
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
var emissions = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
lock (emissions)
{
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
}
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.AlarmId.ShouldBe("HighTemp");
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
}
/// <summary>The projection carries the definition's severity and the message template resolved against
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
[Fact]
public async Task Projection_carries_definition_severity_and_a_resolved_message()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Severity.ShouldBe(AlarmSeverity.Critical);
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
}
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
/// or every deploy would append a duplicate historian / alerts row.</summary>
[Fact]
public async Task Reading_projections_never_fires_an_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var emissions = 0;
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
Volatile.Read(ref emissions).ShouldBe(0);
}
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
/// "nothing to restore" from "restore this".</summary>
[Fact]
public async Task A_normal_alarm_projects_the_no_event_position()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false
using var eng = Build(up, new InMemoryAlarmStateStore());
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
}
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
[Fact]
public async Task Projection_carries_the_last_observed_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
var deadline = DateTime.UtcNow.AddSeconds(10);
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
{
await Task.Delay(20, TestContext.Current.CancellationToken);
}
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
}
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
[Fact]
public void Projections_are_empty_before_the_first_load()
{
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
eng.GetProjections().ShouldBeEmpty();
}
}
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
[InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC DA category byte.</param>
[Theory]
[InlineData(0x00000000u, (byte)192)] // Good
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x40000000u, (byte)64)] // Uncertain
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x80000000u, (byte)0)] // Bad
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
{
[Theory]
[InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
[InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
{
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value);
}

Some files were not shown because too many files have changed in this diff Show More