Compare commits

...

142 Commits

Author SHA1 Message Date
Joseph Doherty e77c8a3569 feat(drivers): tag-set drift detection, gated on SupportsOnlineDiscovery
The second thing #516/§8.2 deliberately did not build. The original objection
stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
DiscoverAsync, so diffing discovered against authored is a tautology for them
— but it is now ENCODED rather than used as a reason not to build.

The discriminator already existed: ITagDiscovery.SupportsOnlineDiscovery is
documented as "enumerates the tag set from the live backend rather than
replaying pre-declared/authored tags", and it separates the fleet cleanly —
FOCAS, TwinCAT, MTConnect and AbCip true; the five config-echo drivers
explicitly false. The check runs only for a driver that is
SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is
nullable rather than empty-by-default on purpose: an empty collection
legitimately means "nothing authored, everything discovered is new", so
conflating the two would report total drift for a driver that never opted in.

Where the value is: AbCip and FOCAS browse a live backend but implement no
IRediscoverable, so before this they had no change signal at all. A periodic
compare is the only way to notice a PLC re-download.

A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their
authored tags into the same DiscoverAsync stream as device-derived ones, so
the browse picker can show an operator their existing tags. Discovered is
therefore always a superset of authored, and a VANISHED tag can never appear
missing — one whole direction is structurally undetectable for three of the
four. Shipping a Vanished list that is permanently empty for them would have
been the half-inert seam this whole exercise removes. So the driver declares
DiscoveryStreamIncludesAuthoredTags, the detector does not compute the
undetectable half, and the operator message says "missing-tag detection
unavailable for this driver" rather than letting the absence of a missing-tag
clause read as reassurance. MTConnect is the only driver where both directions
work — its stream is purely probe-model-derived.

Behaviour: a failed browse is not drift (an unreachable device would otherwise
report every authored tag as vanished); a truncated capture is skipped for the
same reason; an unchanged drift is reported once rather than re-stamping its
timestamp; drift that resolves clears the prompt. Interval defaults to 5
minutes — it browses a real device, and a tag set changing is an engineering
event, not a runtime one. It reuses the Stage-2 surfacing, raising the same
/hosts re-browse prompt, and never rebuilds the served address space.

Comparison logic is a pure, separately-tested type rather than actor-inline.
14 new tests. The gate was verified load-bearing by deleting the
SupportsOnlineDiscovery half: the tautology-driver test goes red, and it
asserts discovery was never invoked rather than merely "no prompt raised",
which would have passed for the wrong reason if the timer never fired.

Full suite green except the same three pre-existing fixture-gated integration
suites, identical counts.
2026-07-28 00:48:39 -04:00
Joseph Doherty 5184a2e107 fix(drivers): complete the Sql + FOCAS in-place config re-parse (#516)
The first #516 pass deliberately left these two out, because each derives more
than options from config: Sql its ISqlDialect and resolved connection string,
FOCAS the IFocasClientFactory its Backend key selects — all injected at
construction. Adopting new options alone would poll a NEW tag set through the
OLD database/backend, and a half-applied config change is worse than a
discarded one because it looks like it worked.

Rather than accept that exclusion, this fixes the blocker. Each factory now
exposes a ParseBinding returning EVERY config-derived dependency as one value,
and the driver adopts them atomically:

- Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds
  everything downstream — provider factory, Endpoint, and the SqlPollReader
  that captures all three. It runs BEFORE BuildTagTable, so the tag table and
  the connection it is polled over always come from the same revision. A
  test-injected DbProviderFactory survives a rebind (_explicitFactory), so a
  re-derived dialect cannot displace what a test passed in.
- FOCAS: options and backend move as a pair in InitializeAsync.

Re-resolving Sql's connection string on reinit is a side benefit: a rotated
credential is picked up without a process restart.

Drivers constructed directly get no rebinder and keep their constructor
binding, so every existing "{}"-passing lifecycle test is unaffected by
construction rather than by luck.

The tests pin ATOMICITY, not merely that a re-parse happened — a test checking
only options would have passed against the broken version. Confirmed by
simulating the half-fix (adopt options, skip the backend): 2 of 3 FOCAS tests
go red. Reverting Sql's rebind turns its connection-string test red. Sql also
asserts that a reinit which cannot resolve its new connection leaves the
previous binding whole rather than half-adopting.

All 12 drivers now honour a changed config in place. Sql.Tests 226, FOCAS.Tests
275 pass.
2026-07-28 00:30:52 -04:00
Joseph Doherty 88ce8df099 docs: close the plan-bookkeeping drift and state the tier truth (§8.5, §8.6, #522)
Bookkeeping: 13 .tasks.json files closed — the 8 the audit named, plus
mesh-phase4 (Task 9 landed 3a590a0c), the 4th stillpending phase file, and
historian-tcp-transport closed as OBSOLETE (it targets the retired Wonderware
sidecar; there is no Wonderware backend in the tree). Each carries a
closureNote naming the evidence rather than just a status flip.

11 archreview live gates written back from STATUS.md, which had recorded them
passed since 2026-07-13/15 without ever updating the task files: R2-01 #11,
R2-02 #15/#18, R2-05 T15, R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.

R2-03 and R2-10 were deliberately NOT closed. STATUS.md's headline says "every
Round-2 live gate is now GREEN", but its own per-item detail says R2-03 is
"live-blocked on this data-less rig" and R2-10 verified the pipeline with
"breaker-OPEN state not forced". Both now carry an openNote recording the
actual blocker.

The point of the sweep: ~140 phantom pending tasks are gone, so the genuinely
unexecuted 19-task AdminUI follow-ups plan is now the largest open item in
docs/plans/ instead of being buried under work that shipped months ago.

Tier truth: docs/v2/driver-stability.md now states that its tier table is
aspirational — every driver runs Tier A because no factory passes a tier, so
MemoryRecycle and ScheduledRecycleScheduler have never engaged, and the config
parser validates a RecycleIntervalSeconds that does nothing. It also corrects a
staleness the audit missed: the separate-Windows-service hosting it describes
for Tier C no longer exists (Galaxy moved to the mxaccessgw sidecar in PR 7.2,
FOCAS went in-process with its managed wire client). docs/drivers/README.md
already said Tier A for both, so the two no longer contradict.

No runtime change — enabling recycle on two live drivers wants its own live
gate, not a docs-pass side effect. Keep-or-delete tracked as Gitea #522.
2026-07-27 19:54:41 -04:00
Joseph Doherty 6a01358b9a fix(adminui): guard the driver dispatch maps and close G-1..G-6 (§8.4)
A parity guard already existed for DriverTypeNames <-> the /raw driver picker,
but not for the two dispatch maps downstream, so a driver could be registered,
offered in the picker, and then have no config form. That is exactly what
happened to Calculation (G-1) and to Sql/MTConnect/Calculation on the device
modal (G-2) — both survived review because nothing could see them.

The maps had to become data first. A Razor @switch compiles into
BuildRenderTree's IL, so no test can enumerate its cases; the picker guard
works only because RawDriverTypeDialog keeps its data in a field the markup
enumerates. Both modals now render from DriverConfigFormMap / DeviceFormMap
through <DynamicComponent>, and being public those maps need none of the
picker test's BindingFlags.NonPublic fragility.

- G-1 CalculationDriverForm.razor — RunTimeout was unauthorable via the UI.
- G-2 Sql/MTConnect/Calculation declared single-connection rather than given
  hollow device forms; each holds one connection at the driver level.
- G-3 DriverConfigModal's hardcoded "Galaxy or Mqtt" -> IsSingleConnection, so
  Sql/MTConnect authors are no longer sent to a device form with no endpoint.
- G-4 DriverFormMapParityTests (5, both directions) + DriverDispatchMapParityTests.
- G-5 CsvColumnMap entries for Sql, Mqtt, MTConnect.
- G-6 RawBrowseCommitMapper Sql branch — and the browser end, which the audit
  missed: SqlBrowseSession emitted NO AddressFields, so a browsed leaf carried
  only a column name, and a column alone cannot address a Sql tag. It now
  travels schema/table/column and the mapper builds a WideRow config from them.
  A branch alone would have produced a half-built blob.

Two findings while writing the guards. The G-6 test showed Modbus and
Calculation also hit the generic address fallback — correctly, as neither is
browsable — so it asserts the fall-through set EQUALS a documented
non-browsable list in both directions; a one-way check would let a newly
browsable driver be quietly added to the exclusion list. And
TagConfigDriverTypeNameGuardTests was hollow: it enumerated a hand-written
TheoryData that had already drifted (omitting Galaxy, Sql and Mqtt), guarding
renames but not gaps. Now enumerated from the map with a coverage test facing
the other way.

Live-verified on docker-dev, since this repo has no bUnit and no unit test
reaches Blazor parameter binding: opened Calculation's config (previously "No
typed config form"), typed 3500, saved, reopened — the value persisted, so the
DynamicComponent two-way binding round-trips. Sql's form renders fully and now
reads "This driver holds a single connection, authored above".

AdminUI.Tests 930 passed.
2026-07-27 19:52:02 -04:00
Joseph Doherty d32d89c340 fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.

Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.

Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
  from each factory, called from InitializeAsync behind a HasConfigBody guard
  so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
  than options from config (Sql's dialect + resolved connection string,
  FOCAS's client-factory backend, both injected at construction), so adopting
  new options alone would run a NEW tag set against an OLD connection. Half a
  re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
  premise — "config parsing belongs to the factory, which builds a fresh
  instance" — which was false when written and is true only now.

Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.

Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.

Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.

Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
2026-07-27 19:32:46 -04:00
Joseph Doherty adce27e7fa refactor(drivers): delete the dead discovered-node injection path (§8.2, #507)
#507 was filed as "injection inert in v3, re-migrate onto the raw subtree".
Reading it, the retained code is not revivable: it resolves equipment from
EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally
empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null
DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set).
Removing the guard would have changed a log line and injected nothing. So it
is deleted rather than fixed, and #507 closes as superseded by /raw
browse-commit.

Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost,
ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver,
_discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper,
DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes,
OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of
CapturingAddressSpaceBuilder/DiscoveredNode.

The connect-time discovery loop goes too (StartDiscovery, RediscoverTick,
HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With
injection gone it had no consumer, and leaving it would either dead-letter or
keep browsing real devices up to ~15x per connect to drop the result.
ITagDiscovery itself stays — the /raw browse picker drives it through
Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor,
so the picker is unaffected.

deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real —
blocked on #507". They are not: they assert an equipment-rooted graft
(EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never
have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the
CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests
in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped:
31 -> 13.

IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses()
has no production call site and nothing writes a DriverHostStatus row, but the
table was re-created in the v3 initial migration, so deleting on inference
would be wrong. Split to Gitea #521 with both options costed.

CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described
the seam as dead.

Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362
passed / 4 skipped.
2026-07-27 18:57:09 -04:00
Joseph Doherty 09a401b881 feat(drivers): consume IRediscoverable as an operator re-browse prompt (§8.2, #518)
Nine drivers raise IRediscoverable.OnRediscoveryNeeded when they observe that
a remote's tag set may have changed — a Galaxy redeploy, a TwinCAT
symbol-version bump, an MTConnect agent restart, a Sparkplug rebirth. Nothing
in src/ subscribed, so every raise went into the void. Drivers even wrap the
invoke in try/catch for "subscriber threw"; there has never been a subscriber.

DriverInstanceActor now attaches the event in PreStart and detaches in
PostStop, mirroring AttachAlarmSource/DetachAlarmSource. Attach is per-actor,
not per-connect: the raise has no connection affinity and can land while the
driver is between connects. The detach is load-bearing rather than tidy — the
IDriver instance outlives the actor when the host respawns a child around the
same driver object, so a missing -= accumulates one handler per respawn, each
holding a dead Self.

The signal rides the existing driver-health snapshot to /hosts as a
"re-browse" chip. It is ADVISORY: the served address space is deliberately not
rebuilt, because v3 authors raw tags explicitly through the /raw
browse-commit flow and a runtime graft would materialise nodes no operator
approved and no deployment artifact records.

Two things worth knowing:

- PublishHealthSnapshot dedups on a health fingerprint, and a rediscovery
  raise on an otherwise-healthy driver changes none of the four fields it
  hashed. Without adding the timestamp to that tuple the dedup swallows the
  exact publish carrying the signal. Reverting that one line turns 3 of the 5
  new tests red, which is how it was confirmed rather than assumed.
- The new test stub implements IDriver from scratch instead of deriving from
  the shared StubDriver, whose GetHealth() returns DateTime.UtcNow — its
  fingerprint differs on every call, so the dedup never engages and the dedup
  test would have passed vacuously either way.

The planned discovery-result drift detector was NOT built. Modbus, S7, MQTT,
AbLegacy and Sql all echo authored config from DiscoverAsync rather than
browsing the device, so a discovered-vs-authored diff is permanently empty for
them — it would have been another plausible-looking inert seam, which is the
class this audit exists to remove. IRediscoverable is the trustworthy signal
because the driver asserts it deliberately.

DriverHealthChanged, IDriverHealthPublisher.Publish and HostsDriverRow gain
defaulted optional parameters, so no existing call site changed. telemetry.proto
gains fields 8/9 and both Phase-5 mappers carry them.

Runtime.Tests 512 passed / 31 skipped.
2026-07-27 18:47:34 -04:00
Joseph Doherty 53ede679c3 docs+ui(security): state plainly that node ACLs are not enforced (§8.1, #520)
deferment.md §8.1 asked for a decision: wire IPermissionEvaluator into the node
manager, or say plainly that ACLs are not enforced. Decision: the latter. The
evaluator stays in the tree; #520 tracks the wire-up.

Verifying the claim turned up four things worse than the audit recorded:

- docs/security.md, not ReadWriteOperations.md, was the highest-risk doc. It
  claimed an anonymous session is "default-denie[d] any node a session has no
  ACL grant for". The opposite holds: anonymous can Browse/Read/Subscribe/
  HistoryRead the entire address space, and is refused writes and alarm acks
  only because it carries no roles.
- Three of five documented data-plane role strings do nothing.
  OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck; ReadOnly,
  WriteTune and WriteConfigure are compared against nowhere in src/. The doc
  called all five "exact, case-insensitive, and code-true".
- ReadWriteOperations.md was fiction beyond the ACL claims. OnReadValue has
  zero occurrences in src/ — the documented read path did not exist. Reads
  never reach a driver: the node manager is push-model and the SDK serves a
  Read from the cached pushed value. WriteAuthzPolicy, _sourceByFullRef,
  _writeIdempotentByFullRef and IRoleBearer are likewise zero-hit, and
  CapabilityInvoker is not referenced by the OpcUaServer project at all. This
  file is rewritten rather than bannered.
- v2-release-readiness.md claimed a whole enforcement layer shipped —
  FilterBrowseReferences, GateCallMethodRequests, MapCallOperation,
  AuthorizationBootstrap, Node:Authorization:* keys. None exist. Whatever
  landed on the v2 branch did not survive into the shipped tree.

The UI change is the operative one: ClusterAcls.razor and AclEdit.razor now
warn that a grant is saved and shipped in every deployment artifact but never
evaluated, so an operator cannot author a deny rule believing it works.

deferment.md gains a §9 execution log tracking §8 remediation.
2026-07-27 18:36:48 -04:00
Joseph Doherty e08855fb9d docs: source-verified deferment register + correct 17 drifted docs
v2-ci / build (push) Successful in 4m52s
v2-ci / unit-tests (push) Failing after 15m58s
Adds deferment.md — a source-verified inventory of new-driver status, all 17
open issues, in-code deferrals, open live gates and plan bookkeeping. Every
claim was checked against src/ and git, not against documentation.

Headline finding: three subsystems are authored, persisted and shipped but
never executed —
  * Node ACLs: IPermissionEvaluator/TriePermissionEvaluator have zero
    production consumers and OtOpcUaNodeManager never references them, yet
    ClusterAcls.razor authors NodeAcl rows and ConfigComposer.cs:51 ships them
    in every artifact. An authored deny rule has no effect.
  * IRediscoverable / IHostConnectivityProbe raise into the void (#518/#507);
    nothing ever writes a DriverHostStatus row.
  * DriverTypeRegistry is vestigial, so no factory passes a tier and every
    driver runs Tier A with the Tier-C protections dormant.

Also records the Calculation driver as picker-visible but unauthorable
(DriverConfigModal has no case) — the "registered but unauthorable" class
recurring after the Sql picker defect — and notes that no parity test guards
DriverConfigModal/DeviceModal, which is why it survived review.

Documentation corrections (source-verified):
  * ReadWriteOperations.md claimed "a denied read never hits the driver" via
    four types that do not exist in src/. Bannered + struck through; a security
    review reading that page would have concluded a per-node ACL gate exists.
  * CLAUDE.md: the Change Detection sentence was false (DriverHost consumes
    nothing); the mesh Phase 4 and Phase 5 live gates and the auto-down 1-vs-1
    gate had all PASSED; the ScriptedAlarmState table was already dropped.
  * driver-expansion tracking: Modbus RTU and SQL poll are merged, not
    pending — its command table would have made someone rebuild two shipped
    drivers in a fresh worktree.
  * drivers/README.md: dead DriverTypeRegistry paragraph, retired
    SystemPlatform namespace kind, missing Sql + Calculation rows, missing
    Modbus RTU-over-TCP transport.
  * TwinCAT.md/Galaxy.md promised an address-space rebuild that never happens.
  * Historian.md gained the #491 unproven-value-capture pointer.
  * IncrementalSync.md and AddressSpace.md bannered as wholesale v2-era (the
    rewrite is recorded as still owed, not done here); four v2 status docs
    bannered as historical; three wrong-name-for-a-live-type fixes.

Left deliberately untouched: the secrets NoOpSecretReplicator line, which
makes a security claim and needs the real behaviour identified rather than a
rename.
2026-07-27 17:26:20 -04:00
dohertj2 90bdaa4437 feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate (#506)
v2-ci / build (push) Successful in 6m38s
v2-ci / unit-tests (push) Failing after 3h0m33s
2026-07-27 14:02:26 -04:00
Joseph Doherty 813e445936 test(mtconnect): close live-gate leg 5 — deploy -> OPC UA read/subscribe PASSED
v2-ci / build (pull_request) Successful in 6m48s
v2-ci / unit-tests (pull_request) Failing after 18m6s
Re-ran the gate on the rebased branch against a freshly rebuilt
otopcua-host:mtconnect image, so it exercises the merged code.

The 2026-07-24 blocker (antiforgery cookie collision between the running
otopcua-dev rig on :9200 and the isolated stack on :9220) is sidestepped
rather than worked around: the headless deploy API
(POST /api/deployments + X-Api-Key) is AllowAnonymous().DisableAntiforgery()
by design, so cookie scoping is structurally irrelevant to it. No browser
needed — the recommended recipe for any future isolated-stack gate.

Both deploys sealed (DeploymentStatus.Sealed, FailureReason NULL) with both
MAIN nodes acking. Read + subscribe verified over opc.tcp://localhost:4860
across all three type paths: Float64 SAMPLE (sinusoid), Int64 EVENT
(monotonic), String EVENT (READY -> INTERRUPTED). This closes the last
unproven link — driver seam was already covered by the live integration
suite; leg 5 adds DeploymentArtifact -> address space -> OPC UA publish.

Two findings recorded in the plan:

- A bad authored dataType degrades to exactly ONE skipped tag, loudly
  (per-tag warning naming the expected keys, BadNodeIdUnknown, "every other
  tag is unaffected") while the rest keep streaming — the intended
  fail-isolated behaviour, demonstrated rather than argued. Correcting it
  took effect with no restart, showing MTConnect is not subject to the
  separately-tracked config-edits-discarded defect.

- fixture_asset_changed reading 0x80000000 is NOT an MTConnect defect. The
  driver maps the agent's UNAVAILABLE to BadNoCommunication correctly; the
  shared publish path collapses every status to a 3-state OpcUaQuality enum
  (DriverInstanceActor.QualityFromStatus, statusCode >> 30) and re-expands
  Bad to StatusCodes.Bad (OtOpcUaNodeManager.StatusFromQuality). Deliberate
  per the enum's own doc comment, but the client-visible consequence appears
  undocumented: no driver's Bad/Uncertain sub-code ever reaches a client.
  Tracked separately; out of scope here.

Also records the rebase onto master 123ddc3f and confirms StatusCodeParityTests
now covers 9 MTConnect constants, all passing.
2026-07-27 13:59:26 -04:00
Joseph Doherty 9d430bee62 docs(mtconnect): correct the MaintenanceMode note; record status-code parity pre-verification 2026-07-27 13:59:26 -04:00
Joseph Doherty 1d0989d3b4 test(mtconnect): live gate on an isolated rig — 4/5 legs pass, deploy leg blocked on a cookie collision (Task 21) 2026-07-27 13:59:26 -04:00
Joseph Doherty 71ccccef7c docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)
- docs/drivers/MTConnect.md: getting-started guide for the P1 Agent MVP —
  config keys read off MTConnectDriverOptions/ConfigDto, capability surface,
  RawPath->dataItemId coercion precedence, UNAVAILABLE->BadNoCommunication +
  the rest of the quality-code table, CONDITION-as-String, browse-via-the-
  universal-browser, the fixture recipe (links the existing IntegrationTests
  Docker README instead of duplicating it), and a Known limitations section
  covering the two pre-existing fleet-wide gaps this build surfaced
  (IRediscoverable/IHostConnectivityProbe have no server consumer; no driver
  form blocks Save on validation). Records write-back (MTConnect Interfaces),
  P1.5 (native alarms, TIME_SERIES arrays, EVENT->enum), and P2 (SHDR ingest)
  as deferred per design doc SS3.6/SS9. Documents where the build diverged
  from the design: TrakHound MTConnect.NET was dropped entirely (no XML
  formatter in the pinned packages, no injectable HTTP handler) in favor of
  a hand-rolled System.Xml.Linq client, namespace-agnostic on LocalName.
- docs/drivers/README.md: adds MTConnect to the ground-truth driver table,
  the per-driver doc list, and the fixture coverage-map list.
- docs/plans/2026-07-24-driver-expansion-tracking.md: marks the MTConnect
  Wave-2 row Done (22/23 tasks; Task 21 live gate tracked separately in the
  plan file, not touched here), corrects mtconnect/cppagent -> mtconnect/agent
  in the fixture note, and links the new driver guide.

Deferred-cleanup decision (Task 1 review follow-up): removed
GenerateDocumentationFile/NoWarn CS1591 from Driver.MTConnect.Contracts's
csproj to match all eight sibling .Contracts projects, none of which carry
it. Verified both the Contracts project alone and the full solution still
build clean (0 warnings, 0 errors) with the flags gone — the existing XML
doc comments don't depend on GenerateDocumentationFile to compile.
2026-07-27 13:59:26 -04:00
Joseph Doherty cf9ce91e51 test(mtconnect): live-Agent docker fixture + integration suite (Tasks 19+20)
The first and only thing in the MTConnect workstream that exercises the driver
against a real Agent; everything else runs on canned XML.

Fixture (Docker/): two services, both stock images with this folder bind-mounted.
- `agent`   mtconnect/agent:2.7.0.12 on :5000. NOTE the repo name: the source
            project is mtconnect/cppagent but the PUBLISHED image is
            mtconnect/agent; mtconnect/cppagent does not exist on Docker Hub.
- `adapter` a stdlib SHDR feeder. Required, not optional: the agent image ships
            only the binary + schemas/styles, so a lone Agent answers /probe and
            then reports every observation UNAVAILABLE forever, which can prove
            nothing about reads or streaming.

Devices.xml seeds one DataItem per inference branch (SAMPLE+units, TIME_SERIES,
PART_COUNT/LINE_NUMBER, controlled vocab, DATA_SET, CONDITION), gives every named
item `name != id` (the inverse of the hand-authored unit fixtures), and leaves one
EVENT permanently unfed so UNAVAILABLE -> BadNoCommunication has a live subject.

Suite (12 tests) asserts STRUCTURALLY against the Agent's own /probe response, never
by literal id, so it survives an edit to Devices.xml and can be pointed at a real
machine tool via MTCONNECT_AGENT_ENDPOINT. It skips cleanly (12/12) when no Agent
answers, and each test carries a hard [Fact(Timeout)] so a half-up fixture cannot
wedge a build.

Three findings from bringing the fixture up, each now pinned in a comment:
- agent.cfg must be pure ASCII; one non-ASCII byte in a COMMENT makes the config
  parser reject the whole file with a bare "Failed / Stopped at line: N" and exit.
- `sampleCount` is an attribute of a TIME_SERIES observation, not of a DataItem
  declaration. A real Agent meeting one on a declaration DROPS THE ENTIRE DATA ITEM
  from the device model, so the inference only ever sees null and a live TIME_SERIES
  tag is always variable-length. Asserted, not ignored.
- The Agent's own <Agent> self-model publishes update-rate SAMPLEs that tick whether
  or not any adapter is attached. Excluding them is load-bearing: with them included
  the "live stream delivered a changed value" test passed with the fixture's data
  source deliberately stopped.

MTConnectError-under-HTTP-200 is NOT covered live and cannot be: cppagent 2.7 answers
an unknown device 404 and an out-of-range sequence 400, each with a well-formed error
body. That shape stays canned-only, and the suite says so.
2026-07-27 13:58:54 -04:00
Joseph Doherty 9227d03ca8 feat(mtconnect): make an MTConnect driver creatable + configurable in /raw (Task 18 Part B)
An MTConnect driver instance could not be authored at all: RawDriverTypeDialog
(the only DriverInstance creation surface) offered a hardcoded 9-entry list
without it, and DriverConfigModal's default: branch renders a warning with no
raw-JSON fallback — so even a hand-inserted row could not be configured and
DriverConfig stayed "{}", on which ParseOptions throws "missing required
AgentUri".

Adds the typed MTConnectDriverForm (the consistent sibling pattern) covering
agentUri / deviceName / the four polling knobs / probe / reconnect, wires the
dispatch case, and offers the type in the dialog.

- All decisions live in the static MTConnectDriverForm.BuildConfigJson +
  MTConnectFormModel (plain C#), because this project has no bUnit.
- The wire shape mirrors the driver's private MTConnectDriverConfigDto, NOT
  MTConnectDriverOptions: the latter's TimeSpan probe knobs would serialise as
  "00:00:05" and never bind to the driver's integer intervalMs.
- arch-review 01/S-6: a non-positive timing knob is REMOVED from the document
  rather than written, so ParseOptions substitutes the driver's own positive
  default instead of the driver faulting at Initialize on RequirePositive.
  Removal (not just skipping) also stops a stale positive value surviving and
  disagreeing with what the form shows.
- Unknown top-level keys survive a load/save, so the driver's hand-authored
  tags[] surface is not silently deleted by opening the form.
- _jsonOpts carries JsonStringEnumConverter, satisfying the
  DriverPageJsonConverterTests fleet guard properly rather than by exemption.

New RawDriverTypeDialogCoverageTests resolves the offered set through
DriverTypeNames.All, so the next registered driver fails here until the dialog
offers it.
2026-07-27 13:58:32 -04:00
Joseph Doherty 115a43dd47 feat(mtconnect): typed AdminUI tag-config editor + browse round-trip (Task 18 Part A)
Registers the MTConnect typed tag editor in TagConfigEditorMap /
TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and
adds the razor shell over Task 17's MTConnectTagConfigModel.

The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units
row is rendered disabled + readonly with NO binding of any kind, and no
inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on
the DataItem `representation`, which the tag-config model does not carry, so
calling it here would agree with the browse picker for ordinary items and
silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones.

Also fixes the picker -> editor round trip, which was broken in both
directions:

- RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed
  tag landed as the generic {"address": "<dataItemId>"} while the model read
  only `fullName`. The data plane kept working (the driver accepts all three id
  spellings), so the only symptom was an empty DataItem-id field in the editor
  — and saving from that state stamped fullName:"" over the tag.
- MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks
  (fixing tags committed before this change) and ToJson normalises onto
  `fullName`, dropping the aliases so the two spellings cannot drift.
2026-07-27 13:58:19 -04:00
Joseph Doherty 51c8c7f30b fix(mtconnect): bind RawTags and key the data plane by RawPath
The driver could not receive data after a real deploy. The runtime hands every
driver its authored tags as a RawTags array (RawPath identity + TagConfig blob,
injected by DriverDeviceConfigMerger) and then subscribes, reads, and routes
published values by RawPath. MTConnectDriverConfigDto declared no RawTags
property, so System.Text.Json silently dropped the array, and every reference
the server passed missed an index keyed by DataItem id: every value came back
BadNodeIdUnknown, with the whole suite green because every test handed the
driver its own Tags array directly.

- MTConnectDriverOptions gains RawTags; the config DTO binds it (verbatim — a
  bad blob is skipped per-tag at binding time, never fails the deployment).
- A new TagBinding, derived from the options and swapped with them in one write,
  carries RawPath -> dataItemId, its one-to-many reverse, and the definition set
  the observation index coerces against. Subscribe/Read resolve through it; the
  fan-out expands each touched dataItemId into every RawPath bound to it and
  publishes under THAT reference — the line the blocker turned on.
- Coercion type precedence: the blob's driverDataType/dataType (both spellings —
  the AdminUI's MTConnectTagConfigModel writes the latter), else a Tags entry for
  the same DataItem, else the Agent's own /probe declaration via
  MTConnectDataTypeInference (what makes a browse-committed {"address": id} blob
  publish as a number), else String. A present-but-invalid type rejects that tag.
- An unmapped reference falls through unchanged, so the dataItemId-keyed CLI
  surface and every existing test keep working, and an unclaimed RawPath reports
  Bad rather than throwing.

15 new tests build their config through the real DriverDeviceConfigMerger; all
four mutations (publish the dataItemId, drop the RawTags binding, drop the
derived coercion type, drop the reverse-map expansion) are caught.
2026-07-27 13:58:06 -04:00
Joseph Doherty b0046d6959 feat(mtconnect): typed AdminUI tag-config model (Task 17)
MTConnectTagConfigModel: pure FromJson/ToJson/Validate for the MTConnect
tag TagConfig JSON blob, mirroring ModbusTagConfigModel/OpcUaClientTagConfigModel.
Fields: fullName (DataItem@id, required), dataType (DriverDataType override,
written as an enum name string), mtCategory/mtType/mtSubType/units (probe
metadata), mtDevice/mtComponent (author context). Preserves unknown JSON
keys across load->save (isHistorized/historianTagname and anything else).

Guards against the enum-serialization trap (numeric dataType would fault the
driver at deploy) both on write (always TagConfigJson.Set via enum name) and
on read (Validate() rejects a dataType that was stored as a bare digit
string, since Enum.TryParse silently accepts numeric text).

AdminUI.csproj gains a ProjectReference to Driver.MTConnect.Contracts,
matching the existing POCO-only driver-Contracts pattern used by the other
six typed tag editors.

Scope: pure model only (Task 17). The razor editor shell + TagConfigEditorMap/
TagConfigValidator registration is Task 18.
2026-07-27 13:58:06 -04:00
Joseph Doherty c232a3ce67 fix(mtconnect): cap CancelAfter overflow + stop leaking ex.Message in probe fallback (review follow-up)
Two review findings on Task 14's MTConnectDriverProbe, both red-first:

- CancelAfter(effectiveTimeout) sat before the try block with only a low-end
  floor on a non-positive timeout, never a high-end cap. A pathological
  timeout (e.g. TimeSpan.FromDays(1000)) threw an uncaught
  ArgumentOutOfRangeException straight out of CancelAfter's ~49.7-day legal
  range, violating the probe's own "Never throws" contract. Not reachable
  through today's only caller (AdminOperationsActor clamps to [1,60]s and
  wraps the call), but a landmine for any future caller without that clamp.
  Fixed by clamping effectiveTimeout to a new MaxTimeout (10 minutes).

- The final `catch (Exception ex) { return new(false, ex.Message, null); }`
  forwarded ex.Message verbatim for any exception type not enumerated above
  it, breaking the redaction discipline every other catch was audited for
  (AgentUri may carry userinfo credentials). Not exploitable by any
  currently-reachable exception type, but an open door for a future one.
  Fixed to build a message from the already-redacted safeUri + the
  exception's type name instead of its message.

Also: a genuine non-2xx stub-handler test (prior HttpRequestException
coverage was connection-refused only), a credentialed-agentUri +
successful-probe redaction test, and a one-line doc remark on the accepted
reachable-vs-deployable gap.
2026-07-27 13:58:06 -04:00
Joseph Doherty a107a1a8b9 feat(mtconnect): host registration + DriverTypeNames const + guard parity (Task 16)
Register the MTConnect factory + probe with the Host, add the
DriverTypeNames.MTConnect constant, and keep DriverTypeNamesGuardTests green.

- DriverTypeNames: new MTConnect const + appended to All.
- DriverFactoryBootstrap: MTConnectDriverFactoryExtensions.Register in
  Register(...) at the default Tier A (managed HttpClient/XML, in-process;
  Tier C is the only tier arming process-recycle, wrong here), and
  TryAddEnumerable(IDriverProbe -> MTConnectDriverProbe) in
  AddOtOpcUaDriverProbes so the probe reaches admin-only nodes (the
  admin-pinned Test-Connect singleton) without double-registering on a
  fused admin,driver node.
- Host.csproj: ProjectReference to Driver.MTConnect (required to compile
  the Register call).
- Core.Abstractions.Tests.csproj: ProjectReference to Driver.MTConnect so
  the guard's bin scan discovers the factory. This and the constant MUST
  land together -- verified RED (2/4 facts) with the constant alone.
- DriverProbeRegistrationTests: MTConnect added to AdminUiDriverTypeKeys;
  its idempotency fact asserts distinct-probe-count and goes RED without
  this (and RED if TryAddEnumerable is downgraded to AddSingleton).

Reconciles all four "MTConnect" literals onto the one constant: the
factory's DriverTypeName, MTConnectDriver.DriverType, and
MTConnectDriverProbe.DriverType now all alias DriverTypeNames.MTConnect
(no circular reference -- Core.Abstractions is a leaf both driver
projects already reference). This is the repo's TwinCat/Focas anti-drift
convention.
2026-07-27 13:58:06 -04:00
Joseph Doherty ba89d6d0ff feat(mtconnect): driver factory delegating to the single config parser (Task 15)
The factory carries NO config DTO of its own: CreateInstance delegates to
MTConnectDriver.ParseOptions, the single authority for the config document.
A second DTO would drift from the parse the runtime's ReinitializeAsync path
uses, and the drift would only surface at deploy time.

Construction is connection-free (agentClientFactory: null) because the Wave-0
universal browser builds a throwaway instance per browse probe, and the
returned instance is never narrowed — its five capability interfaces ARE the
runtime's dispatch surface. Pinned by tests asserting all five plus the
deliberate absence of IWritable.

DriverTypeName is the literal "MTConnect"; Task 16 adds DriverTypeNames.MTConnect
and the host registration that must equal it.
2026-07-27 13:57:46 -04:00
Joseph Doherty 23cd47d54b feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14) 2026-07-27 13:57:46 -04:00
Joseph Doherty dfbcb0e7f0 fix(mtconnect): close the silent-refusal + teardown-wedge defects in the pump (Task 11 review)
C1 (critical): a latched "this endpoint cannot stream" verdict could end with a
GREEN driver holding a permanently silent subscription. DriverInstanceActor
handles a tag-set change as Unsubscribe-then-Subscribe with no re-initialize;
the unsubscribe cleared the stream-degraded flag, the resubscribe was refused by
the latch without a word, and one successful read then reported Healthy.
StartSampleStreamCore now re-asserts the degradation and logs a Warning naming
the remedy, and teardown no longer clears the flag while the latch is set.

NOT promoted to Faulted: DriverHealthReport maps any Faulted driver to a /readyz
503, so that would de-ready the whole node over one misconfigured Agent whose
reads are perfectly healthy. Faulted stays for the case that earns it.

I1: teardown waits are bounded (5 s) and honour the caller's token. They were
unbounded and ignored it, so DriverInstanceActor's 5 s budget was inert and
PostStop — which blocks a dispatcher thread on ShutdownAsync while holding the
lifecycle semaphore — could be wedged forever by one blocking subscriber. The
cancellation source is disposed only when the loop is provably gone. Applied to
StopProbeLoopAsync too: Task 13 reproduced the identical shape, and closing the
class in one method while leaving the other as the pattern to copy is worse than
not closing it.

I2: the reconnect ladder was monotonic for the process lifetime, so ~9 unrelated
drops pinned the driver at MaxBackoffMs forever. A stream that delivered before
dropping now starts a fresh ladder (at attempt 1, not 0, so MinBackoffMs is
still honoured).

I3: MaxBackoffMs <= 0 clamped every delay to zero — an operator-authorable
reconnect spin. Treated as unset, like a multiplier that cannot grow.

I4: the session published instanceId without NextSequence, so after a restart it
held the new agent's id beside the dead one's cursor, and a gap/OUT_OF_RANGE
re-baseline (id unchanged) never published the cursor at all. Both move in one
CAS, and the pump publishes its cursor as it exits.

I5: OnDataChange was a multicast Invoke — the first throwing handler aborted the
rest of the list, starving every later subscriber. Now walked by hand with the
catch inside the loop. The test that claimed to cover this registered the
recorder BEFORE the thrower, so it passed regardless; swapped, it was red.
Faults are latched to one Warning per stream generation (Debug thereafter) so a
consistently-throwing consumer cannot flood the log.

Also: the pump restarts after an unexpected fault (IsCompleted, not null); a
device-scope that matches nothing is a Warning, not a Debug tally; a device or
component with neither name nor id is skipped rather than emitting a blank path
segment; DiscoverAsync's remark no longer claims DriverInstanceActor retries
(it does not for a Once driver, and injection is dormant in v3); and two flake
seeds in the fake are gone (Dispose re-read its own counter; _disposeCts was
disposed under a racing SampleAsync).

439/439. Every changed behaviour falsified by mutation.
2026-07-27 13:57:46 -04:00
Joseph Doherty fa0e40796f feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13) 2026-07-27 13:57:46 -04:00
Joseph Doherty 13dd9fcd70 feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12) 2026-07-27 13:57:46 -04:00
Joseph Doherty 9a67ed918a feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)
One shared /sample long poll behind every subscription handle. Subscribe fires
initial data per reference from the primed /current (Part 4 convention) and
returns without waiting on the stream; the pump runs on its own task under its
own token — never the caller's Subscribe token, which is disposed the moment
that call returns.

The pump owns every way the sequence can break:

- sequence gap, measured against the RUNNING cursor (the previous chunk's
  nextSequence). Comparing against the opening `from` reports a gap on every
  chunk once the buffer rolls — a /current re-baseline storm.
- OUT_OF_RANGE under HTTP 200 (InvalidDataException out of SampleAsync), which
  IsSequenceGap cannot see at all — without it a driver recovers from falling a
  little behind but not from falling a lot.
- agent restart, checked BEFORE the gap because a restart resets sequences and
  so usually trips the gap check too; it clears the index (the held values
  describe a device model that no longer exists) and tells the subscribers.
- every chunk advances the cursor, heartbeats included.

Re-baseline calls CurrentAsync directly on the client the pump already holds:
routing it through ReinitializeAsync would take the non-reentrant lifecycle
semaphore from inside a pump a lifecycle method may already be awaiting, and
hang the driver with no exception and no log. StopSampleStreamAsync is filled
in (same call sites) and InitializeAsync now stops the stream before teardown
too, so a re-Initialize on a live instance cannot leave a pump enumerating a
disposed client.

MTConnectStreamEnded/TimeoutException reconnect under a geometric backoff with
a 100 ms growth floor (MinBackoffMs defaults to 0, and 0 x multiplier is still
0). MTConnectStreamNotSupportedException does NOT retry — it latches, reports
loudly, and is cleared only by a re-initialize.

Health precedence is explicit: /current and /sample fail independently, so each
path owns a flag, clears only its own, and Healthy requires both clear. Neither
can paper over the other's failure.

29 new tests, all deterministic — no sleeps, no wall-clock assertions. The fake
grew per-generation sample streams (so a reconnect has somewhere to land),
scripted /current answers, scripted sample failures, and a chunk-consumed
signal that also fires when the consumer abandons the stream. 375/375 green.
2026-07-27 13:57:46 -04:00
Joseph Doherty a127954cab fix(mtconnect): review remediation for the driver lifecycle (Task 9)
Important 1 - the probe-model triple was read outside _lifecycle while every
write happened under it. Packed (Client, ProbeModel, ProbeModelDataItemCount)
into one immutable AgentSession swapped by a single Volatile.Write, matching
what _index already does, rather than locking the readers: both await the
network, so taking _lifecycle would stall a shutdown for a whole
RequestTimeoutMs and would widen the non-reentrancy hazard. GetOrFetch and
Flush re-publish via CompareExchange so a late re-cache cannot undo a
concurrent teardown/re-init, and a client disposed mid-fetch now surfaces as
the same "not connected" InvalidOperationException browse already handles
instead of a raw ObjectDisposedException.

Important 2 - HasConfigBody decided emptiness by matching the literals "{}" /
"[]", so "{ }", "{\n}" and every pretty-printed empty document fell through to
ParseOptions, failed the required-AgentUri check, and turned a semantically
empty config into a cold-start fault. Now parsed: an object/array with no
elements (or a bare null) is empty; malformed text is deliberately NOT empty so
ParseOptions produces the real quoted error rather than silently starting on
stale options.

Important 3 - documented the _lifecycle non-reentrancy hazard in the code, on
both the semaphore and StopSampleStreamAsync, naming Task 11's re-baseline as
the specific path that would deadlock and giving the CurrentAsync-directly
pattern that avoids it.

Important 4 - removed the Initialize/Reinitialize asymmetry rather than
documenting it. Both now share one rule via ResolveIncomingOptions: an
unreadable config document never destroys working state, and faults only a
driver that had none. InitializeAsync previously tore down before parsing, so a
bad document on a live instance destroyed a healthy client - the exact outcome
ReinitializeAsync was written to avoid.

Minors: SafeDispose traces the swallowed disposal fault at Debug (a client that
cannot be released is how a handle leak starts); the RequirePositive test is a
Theory over all four timing knobs, not just RequestTimeoutMs (arch-review
01/S-6); the footprint test no longer overclaims "is zero before initialize".

CannedAgentClient gains a one-shot ProbeGate so a lifecycle change landing
mid-request is deterministic - no timers. 346/346 (329 + 17). Six mutations
verified: fetch-CAS, disposed-translation, flush retired-session guard,
literal HasConfigBody, teardown-before-parse, and two dropped RequirePositive
calls each fail only their own tests.
2026-07-27 13:57:46 -04:00
Joseph Doherty 1bfc1722b3 feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)
One deadline-bounded /current per batch, answered out of that one whole-device
document: N references cost one round-trip, one snapshot each, in request order,
duplicates included.

DEVIATION (deliberate, from the plan's implied shared-index write): the response
is indexed into a PER-CALL snapshot index, never into the driver's shared
observation index. That index is written by Task 11's /sample pump and is
last-write-wins with no timestamp arbitration; a read folding its /current into
it would be a second writer racing the first, and /current is frequently the
OLDER document (the Agent composes it while the pump's newer delta is in
flight). Losing that race rolls a subscribed value backwards and leaves it there
until the data item next changes. Both paths coerce through the same
MTConnectObservationIndex logic, so a read and a subscription report a given
observation identically -- the read path is simply a pure function of
(document, authored tags).

Failure posture is the inverse of InitializeAsync: nothing but genuine caller
cancellation crosses the capability boundary. Unreachable Agent / blown deadline
/ unusable answer => every ref codes BadCommunicationError and the driver
degrades (never downgrading Faulted); no client at all (pre-initialize, faulted,
post-shutdown) => BadNotConnected with health untouched. The index's
BadWaitingForInitialData / BadNodeIdUnknown / BadNoCommunication / BadNotSupported
distinctions pass through unmodified.

21 tests pinning arity, order, duplicates, empty batch, one-round-trip, each Bad
code, read-time freshness, the anti-clobber invariant, the deadline, and
cancellation-propagates-but-agent-failure-does-not. CannedAgentClient gains a
cancellation-observing CurrentGate (still no timers or sleeps of its own).
Test csproj takes the AbCip/S7 OTOPCUA0001 NoWarn -- this suite calls the
capability directly by design.

329/329 green.
2026-07-27 13:57:46 -04:00
Joseph Doherty 4fe0af3693 feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)
MTConnectDriver implements IDriver only: connection-free ctor, initialize
(deadline-bounded /probe + /current, caching the device model, the agent
instanceId, and a primed observation index), reinitialize, shutdown, health,
footprint, and cache flush.

Decisions worth knowing:

- The driverConfigJson argument is HONOURED, not ignored. DriverInstanceActor
  delivers a config change only via ReinitializeAsync(json) on the live
  instance, so a driver reading only its ctor options would turn every MTConnect
  config edit into a silent no-op that still sealed the deployment green.
  ParseOptions lives on the driver; Task 15's factory must delegate to it rather
  than deserialize a second DTO.
- /probe ok but priming /current failing is Faulted, not Healthy-with-no-data:
  the index IS the read surface and the /sample cursor comes from /current.
- ReinitializeAsync rebuilds the client whenever ANY option the client reads at
  construction changed - AgentUri, DeviceName, RequestTimeoutMs, HeartbeatMs,
  SampleIntervalMs, SampleCount - not just the first two. The timing knobs are
  baked into the deadlines, the watchdog window, and the /sample query string.
- An unparseable config faults a cold start but leaves a RUNNING driver serving
  its previous valid configuration (the deployment fails instead).
- IMTConnectAgentClient now extends IDisposable rather than the driver doing
  `as IDisposable`, which would silently no-op against a non-disposable fake and
  leak an HttpClient + socket handler per re-deploy. Sync, not async: every
  resource behind the seam is sync-disposable, and the async unwind belongs to
  the SampleAsync enumerator the pump owns.
- Flush drops the probe/browse cache and keeps the index; every model consumer
  goes through GetOrFetchProbeModelAsync so a flush cannot wedge browse.

Tests: CannedAgentClient (shared, deterministic - channel-backed scripted
/sample with no timers, call counts, disposal tracking, failure injection) +
27 lifecycle tests. 308/308 in the project.
2026-07-27 13:57:46 -04:00
Joseph Doherty 908bd7ba4a fix(mtconnect): deterministic concurrency test + DATA_SET -> BadNotSupported
The concurrency test asserted a post-Apply post-condition from inside the
race window: dev1_pos is authored, so a reader beating the first Apply
correctly observes BadWaitingForInitialData, which the test's two-member
"legal" set omitted. Consistently red (10/10) rather than intermittent.

Rewritten to assert only properties that hold at every instant — the whole
(status, value, source timestamp) triple against the three legal snapshots,
which is the real torn-snapshot check — plus a read counter guarding a
vacuous pass and one deterministic closing Apply for an exact end state.
The index is unchanged here; BadWaitingForInitialData is a designed state.

Also codes structured (DATA_SET / TABLE) observations BadNotSupported now
that the parser supplies MTConnectObservation.IsStructured, so a two-entry
data set no longer surfaces as the Good string "12".
2026-07-27 13:57:46 -04:00
Joseph Doherty 5ba9f1be6f fix(mtconnect): make a /sample stream end loud, and correct the gap contract (Task 7 review)
Code review of c46540ae approved the framing algorithm but moved the risk
onto the contract around it.

C1 (critical). SampleAsync returned normally on three different events:
EOF, the closing --boundary--, and a response that was not multipart at
all (where it yielded one parsed snapshot and finished). The seam
documents the stream as yielding until ct is cancelled, so a Task 11 pump
written to that contract would silently drop its subscription or hot-loop
reconnecting; the non-multipart leg reported a configuration error as a
healthy finished stream, which is the #485 quiet-successful-termination
shape aimed at the very next task. Every non-cancellation end now throws:
MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary -
transient) or MTConnectStreamNotSupportedException (configuration -
reconnecting reproduces it forever), sharing one catchable base. The
non-multipart body is still parsed first so an Agent's own MTConnectError
text wins, but the document is NOT yielded.

I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first
parameter is now expectedFrom. The old name and doc were wrong for every
chunk after the first - the correct comparand is the PREVIOUS chunk's
NextSequence, and comparing against the opening "from" argument reports a
gap on every chunk once the ring buffer rolls, i.e. an endless /current
re-baseline storm. Both doc blocks also now record that cppagent answers
from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200,
which surfaces as InvalidDataException and NOT as a gap - so Task 11 must
treat that parse failure as a re-baseline trigger too.

I2. Every multipart test served the whole body from one buffer, so every
framing test completed in a single ReadAsync - the split-boundary path,
the split-header path and the multi-fill loop were correct by inspection
only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the
fixtures through a split transport, plus a >16 KB document that forces a
buffer resize. Falsifiability: removing either scan overlap, or
collapsing the fill loop, fails ONLY the new chunked tests.

I3. Disposal mid-enumeration surfaces as OperationCanceledException via
an internal dispose-linked token, not a raw ObjectDisposedException that
Task 9's re-init would inflict on an enumerating pump.

I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs
in construction-time positive-value validation. All three reach the query
string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError
that names no config key.

I4/M2. The no-Content-length framing fallback logs a one-shot warning
(the client takes an optional ILogger); a multipart response with no
boundary parameter fails fast instead of degrading into a watchdog
timeout.

M5/M6. Shared element/attribute reading rules extracted to MTConnectXml;
the probe parser documents why it alone does not trim BOM/whitespace. The
literal U+FEFF in source became a '' escape.

Also, from Task 8: MTConnectObservation gained IsStructured (default
false), set from element.HasElements. A DATA_SET/TABLE observation's
<Entry> children concatenate through element.Value to nonsense ("12" for
two entries) that the index would publish as Good, and only the parser
can tell that apart from a legitimate space-bearing Message. False for
CONDITION observations - their value comes from the element name, so
child content cannot corrupt it.

275/275 green in this suite's own files.
2026-07-27 13:57:46 -04:00
Joseph Doherty ac0a284055 feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8) 2026-07-27 13:57:46 -04:00
Joseph Doherty cf43f8d0ab feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)
Adds the /current and /sample legs to MTConnectAgentClient:

- MTConnectStreamsParser: hand-rolled System.Xml.Linq parse of an
  MTConnectStreams document into MTConnectStreamsResult. Observations are
  every element child of <Samples>/<Events>/<Condition>, keyed by
  dataItemId (the element NAME is the data item's type in PascalCase, so
  a fixed name set would drop observations). A CONDITION's value is its
  element name, not its text - <Normal/> is empty - and <Unavailable/>
  normalizes onto the same UNAVAILABLE sentinel a Sample/Event carries as
  text, so Task 8's no-comms mapping sees one token. Timestamps are
  normalized to DateTimeKind.Utc explicitly.
- IsSequenceGap(requestedFrom, chunk) => chunk.FirstSequence >
  requestedFrom, so Task 11's pump can re-baseline after a ring-buffer
  overflow. Equality is NOT a gap.
- SampleAsync: ResponseHeadersRead + a hand-rolled
  multipart/x-mixed-replace frame reader (Content-length fast path,
  boundary-scan fallback), bounded by a heartbeat watchdog derived from
  HeartbeatMs so a silent Agent faults instead of wedging the pump - the
  S7 frozen-peer shape (arch-review R2-01). The long poll gets its own
  HttpClient with an infinite Timeout so the unary /probe + /current
  deadline stays bounded.

Task 0 package decision, option (c): both TrakHound PackageReferences and
their PackageVersions are dropped. MTConnectHttpClientStream takes a URL
and dials its own socket (no handler/stream seam, so it cannot serve a
socket-free unit suite), and it emits already-parsed documents through the
formatter lookup Task 6 proved is missing - framing-only reuse was never
actually on offer. The driver now depends on nothing but the BCL. Recorded
in the plan's Task 0 CORRECTION block.

Also folds in two Task 6 review carry-overs: the stale
IMTConnectAgentClient doc comment claiming a TrakHound-backed
implementation, and a probe test constructing a mixed-namespace vendor
extension (<x:Pump> with default-namespace <DataItems> children) that
backs the parser's ignore-the-namespace claim with a test.

187/187 green.
2026-07-27 13:57:46 -04:00
Joseph Doherty d3aaa4a411 docs(mtconnect): correct the Task 0 decision — TrakHound cannot parse XML as pinned 2026-07-27 13:57:28 -04:00
Joseph Doherty 1990d21733 feat(mtconnect): agent client /probe parse to device model (Task 6) 2026-07-27 13:57:28 -04:00
Joseph Doherty b13cf5c6e8 fix(mtconnect): options carry the authored tag definitions (Task 2 follow-up) 2026-07-27 13:57:28 -04:00
Joseph Doherty 79e30d1ead fix(mtconnect): infer numeric EVENT types from the probe's UPPER_SNAKE spelling (Task 3 follow-up) 2026-07-27 13:57:28 -04:00
Joseph Doherty 3ca8ddf6f7 test(mtconnect): canned probe/current/sample XML fixtures incl. forced-gap (Task 4) 2026-07-27 13:57:28 -04:00
Joseph Doherty c0729ae5c0 feat(mtconnect): pure data-type inference table + golden test (Task 3) 2026-07-27 13:57:28 -04:00
Joseph Doherty 992ffe5620 feat(mtconnect): IMTConnectAgentClient seam + neutral parse DTOs (Task 5) 2026-07-27 13:57:28 -04:00
Joseph Doherty 18742ea92b feat(mtconnect): driver options + tag definition records (Task 2) 2026-07-27 13:57:28 -04:00
Joseph Doherty e36b920113 build(mtconnect): scaffold Contracts+Driver+Tests projects, add to slnx (Task 1) 2026-07-27 13:57:28 -04:00
Joseph Doherty 3b92b7cc83 docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0) 2026-07-27 13:57:11 -04:00
Joseph Doherty a0b5095b3b docs(drivers): un-stale the MQTT README rows + track every known gap as an issue
v2-ci / build (push) Successful in 4m41s
v2-ci / unit-tests (push) Failing after 16m11s
The cross-driver README still described MQTT as pre-P2 — "Sparkplug B is P2
(`mode: SparkplugB` is a placeholder)" and "Sparkplug B has no fixture yet (P2)".
Both were false as of the P2 merge (c3a2b0f7): SparkplugB is a fully implemented
mode and the live suite is 15 tests (7 plain + 8 Sparkplug) against a
project-owned C# edge-node simulator. Mqtt.md and the scadaproj index were both
already correct; only this file missed the P2 sweep.

Filed the nine Known-gaps rows as tracking issues and linked them, so the table
stops being the only record of them:

  #507  IRediscoverable is inert in v3 (platform gap — also Galaxy/TwinCAT/AbCip)
  #508  write-through (IWritable) — NCMD/DCMD + plain publish
  #509  a metric name containing '/' cannot be browse-committed
  #510  _canRebirth captured at browse-open — reaped session still draws button
  #511  Sparkplug primary-host STATE publishing (ActAsPrimaryHost)
  #512  .Browser references .Driver — extract the client-options builder
  #513  editor writes a meaningless payloadFormat into a Sparkplug blob
  #514  Request-rebirth unarmable on a plant that birthed before the window
  #515  DataSet / Template / PropertySet metric support

#507 is the one worth reading: it is a v3 platform gap, not an MQTT bug —
DriverHostActor.HandleDiscoveredNodes hard-returns, so every IRediscoverable
driver fires into a void and the edge gaining a point needs a redeploy to show up.

No code change; docs + issue links only.
2026-07-27 13:52:08 -04:00
Joseph Doherty 394558e6c2 docs: the umbrella-index edit belongs on scadaproj's main, and must be pushed
v2-ci / build (push) Successful in 5m39s
v2-ci / unit-tests (push) Failing after 3h12m23s
The cross-repo propagation rule said WHAT to update but not WHERE. scadaproj is
a separate repo with its own checkout, so committing there inherits whatever
branch it happens to be on — usually an unrelated feature branch, sometimes one
with no upstream — and the index then disagrees with reality for as long as that
branch stays unmerged.

Observed 2026-07-27: the Sql poll driver's index entry had sat unpushed on
feat/overview-dashboard since 2026-07-24 despite this rule, and the Mqtt driver's
entry was about to join it. Both recovered by cherry-picking onto main.

Records the explicit procedure and says to cherry-pick stranded index commits
rather than merge the branch they are stuck on. The canonical statement now also
lives in scadaproj's own CLAUDE.md (main @ 75f267c).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-27 13:36:31 -04:00
Joseph Doherty c3a2b0f773 Merge branch 'feat/mqtt-sparkplug-driver'
v2-ci / build (push) Successful in 5m36s
v2-ci / unit-tests (push) Failing after 22m40s
# Conflicts:
#	src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
#	src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
2026-07-27 13:31:39 -04:00
Joseph Doherty 123ddc3f40 fix(scripteditor): complete + hover absolute RawPaths (#490)
v2-ci / build (push) Successful in 4m25s
v2-ci / unit-tests (push) Failing after 14m58s
The issue reports a missing feature. The cause is a stale projection, and the
consequence was worse than the gap described.

`ScriptTagCatalog` still emitted `Tag.TagConfig.FullName` — the pre-v3 driver wire
address. Since v3 the mux key is the **RawPath**: `AddressSpaceComposer` harvests the
`ctx.GetTag("…")` literals into `DependencyRefs`, those become `VirtualTagActor`
dependency refs registered with `DependencyMuxActor`, whose `_byRef` is keyed Ordinal
by `AttributeValuePublished.FullReference` — and a driver's wire ref IS the RawPath
(`DriverHostActor._nodeIdByDriverRef` is keyed `(DriverInstanceId, RawPath)`). The
`{{equip}}/<RefName>` form already substitutes to RawPaths at both compose seams.

So the catalog was wrong in BOTH directions, not merely incomplete: completion
offered paths that could never resolve at runtime, while a CORRECT absolute RawPath
got no completion and hovered as "not a known configured tag path". The file's own
comment admitted the raw/UNS catalog was left for "Batch 2/3" and it never came.

Now projected through the shared `RawPathResolver` — the same byte-parity authority
`DraftValidator` and `AddressSpaceComposer` use — so a suggested path is identical to
the one the deploy gate and the runtime compute. A tag with a broken ancestry chain
resolves to null and is omitted: the runtime could not route it either, so offering
it would be a lie. Hover additionally gains the owning `DriverInstanceId`, which the
topology now makes available (it was always null before).

Editor-accepts <=> publish-accepts is preserved: no diagnostic keys off this catalog
(`OTSCRIPT_EQUIPREF` only marks `{{equip}}` paths), so this changes completion and
hover only — it cannot newly reject a script.

The existing tests pinned the v2 contract and were re-authored: a RawPath only exists
if the RawFolder -> DriverInstance -> Device -> TagGroup -> Tag chain does, so the
seed now builds one. Added coverage for group-nested paths, a driver at the cluster
root (no folder segment), a broken chain being omitted rather than guessed, and the
pre-v3 FullName no longer resolving.

Live-gated on docker-dev (rig rebuilt, both central nodes):
- completion, empty literal -> every RawPath incl. group-nested
  `opcua1/plc/OpcPlc/Telemetry/Basic/AlternatingBoolean`
- completion, prefix `pymod` -> `pymodbus/plc/HR200X`, `pymodbus/plc/ImportedTag`
- hover `pymodbus/plc/HR200X` -> "Tag · Type UInt16 · Driver MAIN-modbus"
- hover an unknown path -> "Not a known configured tag path"

AdminUI 742/742; script-analysis 55/55; solution builds 0 errors.
2026-07-26 11:08:11 -04:00
Joseph Doherty 0f38f48679 merge: Modbus RTU-over-TCP transport (#495)
Wave 1 of the driver-expansion program. The branch was complete and live-gated but
had never been merged; it sat 15 commits ahead and 50 behind master.

Purely additive behind the existing IModbusTransport seam — the application protocol
(function codes, codecs, planner, coalescing, write path, probe, materialisation,
HistoryRead) is identical across TCP and RTU; only the wire framing differs
([addr][PDU][CRC-16], no MBAP, no TxId). Zero changes to codecs/planner/health.

Selected by a new Transport config field (ModbusTransportMode: Tcp | RtuOverTcp),
routed through ModbusTransportFactory (throws on unknown mode), with the string-enum
guard on options/DTO/factory and a Transport selector on the AdminUI Modbus form.
ModbusSocketLifecycle was extracted from ModbusTcpTransport (behaviour-preserving)
and is composed by both transports.

Descoped by design: direct-serial (System.IO.Ports) and Modbus ASCII — RTU buses are
reached exclusively via a serial->Ethernet gateway. No per-tag config changes; the
existing per-tag UnitId already makes a multi-drop bus work.

Re-validated against current master at merge time (the branch's own gate predates 50
commits of master): no overlapping files, clean merge, solution builds 0 errors, and
Modbus 344/344, Modbus.Addressing 161/161, AdminUI 740/740, Runtime 507/507,
Configuration 131/131 all pass. Confirmed the Transport selector is still reachable
through master's current authoring path (DriverConfigModal -> ModbusDriverForm), so
the branch's live /run gate result still applies.
2026-07-26 10:58:51 -04:00
Joseph Doherty 0e93587b1c merge: test-correctness, config-secret and AdminUI fixes (#503 #501 #493 #499 #488 #505)
v2-ci / build (push) Successful in 4m14s
v2-ci / unit-tests (push) Failing after 14m48s
Six issues closed, one new one found, filed and fixed.

- #503 Galaxy EventPump flake — root cause was cross-test measurement leakage on a
  STATIC Meter, not a timing budget. 15/15 clean under the issue's own contention
  recipe. Three further defects in the same test fixed on the way, including a
  "held" dispatch loop that was never held (async void on EventHandler<T>) and an
  assertion built from a derived quantity, which could only fail spuriously.
- #501 Three absence assertions given positive controls (a fourth site the issue
  did not list). Proven falsifiable by breaking the production code: both breaks
  passed under the old AwaitAssert form.
- #493 docker-dev seed backfills ClusterNode.GrpcPort; upgrade step documented.
  Deliberately no EF migration — the right value is per-node config the DB cannot
  derive, and a wrong dial target is worse than a NULL that is already skipped.
- #499 Sql credentials refused in ClusterNode.DriverConfigOverridesJson, gated at
  the SAVE rather than the deploy: node overrides never enter the artifact, so a
  deploy gate would not stop the credential being stored. Live-verified.
- #488 Periodic desired-vs-actual subscription reconcile (option A), gated on
  ISubscribable so a non-subscribable driver is not Subscribe-spammed forever.
- #505 The AdminUI cluster-node editor had never worked at all — HTTP 500 on load
  (InputNumber<byte>) and Save a silent no-op (NodeId regex forbids the ':' every
  NodeId contains), with no ValidationSummary to reveal it. Pre-existing on master,
  invisible to 739 green AdminUI tests, found only by going to drive the page.

Also closed #492 (already fixed by 2964361a and drill-verified; no code change).

Verified: solution builds 0 errors; 2406 tests pass across Runtime (507), AdminUI
(739), Configuration (131), Galaxy (317), Cluster (147), Core (260),
ScriptedAlarms (82) and Sql (223). #499 and #505 live-gated on docker-dev with both
central nodes rebuilt; rig restored afterwards.
2026-07-26 10:51:43 -04:00
Joseph Doherty 4cc0215bb4 fix(adminui): the cluster-node editor was dead — 500 on load, silent no-op on save
Both defects are PRE-EXISTING (present on master, unrelated to #499) and were found
live-verifying the #499 guard, which lives on this page and was unreachable without
them fixed.

1. **HTTP 500 on load.** `FormModel.ServiceLevelBase` was `byte` to match the
   entity, and `InputNumber<T>`'s static constructor throws outright for
   `System.Byte` — "The type 'System.Byte' is not a supported numeric type". That
   escapes `BuildRenderTree` unhandled, which in Blazor takes the WHOLE page down,
   not one field. `/clusters/{id}/nodes/{nodeId}` and `.../nodes/new` have never
   rendered. Now `int` with the same `[Range(0, 255)]` and a cast on the way to the
   entity, so the stored type is unchanged. (Same failure mode as #504: an
   unhandled throw inside the render tree is a page-kill.)

2. **Save did nothing, silently.** `NodeId` was validated against
   `^[A-Za-z0-9_-]+$`, which forbids the colon — but every NodeId in this system is
   `host:port` (`ClusterRoleInfo` and `ConfigPublishCoordinator` derive it as
   `member.Address.Host:Port`, the seed writes `central-1:4053`, and
   `NodeDeploymentState.NodeId` is FK-bound to it). So every existing node failed
   validation, `OnValidSubmit` never ran, and the button was inert. Pattern now
   allows `:` and `.` (hostnames may be dotted).

3. **Added the missing `<ValidationSummary/>`.** The form had a
   `DataAnnotationsValidator` but nothing rendering its output, so a validation
   failure produced no feedback at all — which is precisely how (2) stayed
   invisible. This is the change that stops the next such defect being silent.

Live-verified on docker-dev (both central nodes rebuilt, since :9200 round-robins
the pair): page 500 → 200 on both routes, and a real edit now persists.
2026-07-26 10:42:04 -04:00
Joseph Doherty 4b9bcddbcc feat(drivers): periodic reconcile of desired vs actual subscriptions (#488)
Option A from the issue — the cheap consistency check, no interface change.

The desired ref set was re-applied on a Connected TRANSITION (`ResubscribeDesired`)
and on a deploy's `SetDesiredSubscriptions`, and nowhere else. A subscription lost
while the driver STAYED Connected — a subscribe that failed and was never retried,
a handle dropped down an error path — left the actor subscribed to nothing while
still reporting Healthy, indefinitely.

`ReconcileSubscription` now rides the existing 30 s `health-poll` tick: while
Connected, a driver holding a desired set but no live handle is re-subscribed.

Gated on `ISubscribable`. A driver that cannot subscribe at all can still be handed
a desired set, and without the guard it would be told `Subscribe` every tick and
fail every tick, forever.

This is a state-machine consistency check, NOT a probe — it can only see that this
actor holds no handle, never that a handle is present but dead server-side, because
`ISubscriptionHandle` is opaque. That is option B, and it stays deferred until a
real occurrence shows the handle outliving the subscription; it would need a
liveness member implemented across all eight drivers with different subscription
mechanics.

A redundant `Subscribe` is possible (a tick queued ahead of an already-queued one
observes the same no-handle state) and is harmless: `HandleSubscribeAsync` is a
`ReceiveAsync`, so no tick is processed mid-subscribe, and it drops any prior
subscription before establishing the new one. Suppressing it would need a
pending-subscribe flag threaded through every self-tell site — more state than the
race is worth.

`healthPollInterval` becomes an optional Props parameter, mirroring the existing
`rediscoverInterval` seam, so a test can drive the reconcile without waiting 30 s.

Three tests. The reconcile itself is proven by a stub whose FIRST subscribe throws:
nothing else would ever retry it, so a second attempt can only come from the
reconcile — disabling `ReconcileSubscription` turns it red. The two guard tests are
absence assertions and are bounded by a positive control rather than a sleep: a
counting health publisher makes ticks PROCESSED observable, so "still one subscribe"
means the reconcile ran and declined, not that the timer had not fired.

Explicitly not deploy-time drift (#486) — re-asserting an already-correct desired
set would not have helped there.
2026-07-26 10:30:19 -04:00
Joseph Doherty 2193d9f2e4 fix(config): refuse to store a Sql credential in a node config override (#499)
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config
is persisted on, and the only one nothing gated. It is a map keyed by
`DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal
`connectionString` pasted there leaks exactly as one pasted into the driver — the
leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`.

Gated at the SAVE, not at the deploy — a deliberate departure from both options the
issue offered:

- `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the
  snapshot and every builder of one for a single rule about a value that never
  enters the artifact.
- More to the point, a deploy gate is the wrong instrument here. Node overrides are
  not in the artifact, so blocking a deploy would not stop the credential being
  stored — it is already in the database and replicated by then. Refusing the save
  is the only point where "refuse to store it" is literally true, which is #498's
  own framing: discarding a secret on read is not the same as refusing to store it.

The check moves into a shared `SqlCredentialGuard` so the two enforcement points
cannot drift; `DraftValidator` now calls it instead of its own private helper.

Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for
instance ids that ARE Sql drivers. The issue asked whether to generalise to
credential-shaped keys across every driver type; not doing that, for the same
reason #498 did not: a broader sweep would start refusing configs that are
legitimate today for drivers which never made an indirect-credential guarantee,
which is a regression rather than defence in depth. Widen per driver, as each gains
its own contract.

The error names the offending driver instance(s) and NEVER the value — it reaches
the AdminUI and the audit trail.

14 new cases cover both halves, including the case variants (System.Text.Json binds
`ConnectionString` to `connectionString`, so a case variant is the same key, not a
bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
2026-07-26 10:30:04 -04:00
Joseph Doherty 8b7ea3213d fix(rig,docs): backfill ClusterNode.GrpcPort and document the upgrade step (#493)
`GrpcPort` is a nullable column added by Phase 5. Every insert in the docker-dev
seed is INSERT-if-not-exists — the right shape for a re-runnable seed, but it means
a new nullable column never reaches rows an earlier version of the file created. On
a persisted volume all six rows kept NULL, central found no dial targets, and
`TelemetryDial:Mode=Grpc` connected to nothing. (`AkkaPort` escaped this only
because it is NOT NULL with a default of 4053.)

Seed now backfills `GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'` to 4056.
NULL only — it must not overwrite a port an operator set deliberately on a
long-lived dev volume, and NULL is the unambiguous "predates the column" marker.

Deliberately NOT doing the suggested EF data migration for real deployments. The
correct value is each node's own `Telemetry:GrpcListenPort`, which is
per-deployment configuration the database cannot derive; a migration could only
invent one, and a WRONG dial target is worse than the NULL the dialer already skips
explicitly with a throttled warning. The rig can be backfilled because there the
port is fixed by docker-compose.yml and therefore actually knowable.

`docs/Telemetry.md` gains an upgrade section stating the prerequisite, the symptoms
(the throttled skip log, LISTEN with no ESTABLISHED, the pill never going live) and
the UPDATE to run before flipping any node to Grpc — plus why there is no migration.
2026-07-26 10:29:51 -04:00
Joseph Doherty 240d7aa8aa fix(test): give the three absence assertions a positive control (#501)
`AwaitAssert` returns on its FIRST success, so an assertion that is already true at
t=0 passes instantly and spends none of its duration. All three sites asserted an
absence against a collection that starts empty, so none of them ever gave the
handler the time their comments claimed.

Replaced with mailbox-ordering positive controls rather than sleeps:

- `RouteNativeAlarmAck_unknown_node_is_dropped` — follow the unmapped ack with a
  MAPPED one. The host forwards via `Tell` and the child handles `RouteAlarmAck`
  with `ReceiveAsync` (which suspends its own mailbox), so once the control reaches
  the driver the unmapped one has provably been handled.
- `RouteNativeAlarmAck_on_non_primary_is_dropped` — the role itself is the control:
  return the node to Primary and re-send. Comments (`gated` / `control`) are the
  discriminator, so a leaked ack is visible rather than merged into a count.
- `PeerProbeSupervisorTests.Single_node_snapshot_spawns_no_children` — a FOURTH site
  the issue did not list. It says the other `ChildCount.ShouldBe(0)` waits are each
  preceded by a `ShouldBe(1)`; that is true of three of the four, but not this one,
  which asserts the initial state. Now followed by a two-node snapshot: the count
  reaching 1 proves the single-node snapshot was processed, and a local-node child
  would have made it 2.

The issue warned these may turn red, which would be a real defect surfacing. They
did not — the routing is correct. Proven by breaking it deliberately instead:
disabling the Primary gate reddens the Secondary test, and routing unmapped ids to
an arbitrary mapping reddens the unknown-node test. Under the old form both breaks
passed.
2026-07-26 10:29:41 -04:00
Joseph Doherty 95295210b0 fix(test): scope the EventPump counter capture to its own pump (#503)
Root cause was cross-test measurement leakage, not a timing budget.

EventPump's `Meter` is STATIC — one instance per process — and a `MeterListener`
can only select by *meter* in `InstrumentPublished`. `StartMeterCapture` therefore
received measurements from every `EventPump` alive anywhere in the assembly. xUnit
runs test classes in parallel and several build pumps (`EventPumpStreamFaultTests`,
the `Driver-X` test in this same file), so a sibling's events landed in these
counters. That is why the flake needed a busy box: it needed two tests to overlap.
Measured directly during the gate — `Received = 11` in a run that emitted 10.

The old `Received == Dispatched + Dropped + InFlight` assertion is what turned the
leak into a failure. `InFlight` is DERIVED as `Received - Dispatched - Dropped`, so
the identity is a tautology that cannot fail on a real defect — only on a foreign
increment landing between its four separate `Interlocked.Read` calls. Hence the
reported subject, `counters.Received`. It is deleted, along with the `InFlight`
member, and replaced by direct assertions on the three MEASURED counters.

Three further defects in the same test, all found on the way:

- **The "held" dispatch loop was never held.** `OnDataChange` is `EventHandler<T>`
  — void-returning — so `async (_, _) => await gate` is async VOID: it returned to
  the dispatch loop at the first await and blocked nothing. Measured `Dispatched =
  2..3`, never 0. Drops happened only when the producer happened to outrun the
  consumer, so the arrangement the doc comment describes was fiction and the
  assertions rode on scheduling luck. Now a synchronous `ManualResetEventSlim.Wait()`
  on the loop's own thread, pinned by `Dispatched.ShouldBe(0)`.
- **Fixed `Task.Delay(150)`** replaced by a presence poll on the drop count (#500's
  rule). Dropped starts at 0, so it cannot be satisfied by the initial state.
- **Emission is staged** — one event, wait for the dispatcher to enter the handler,
  then the other nine — so the arithmetic is exact (`10 - 1 held - 2 buffered = 7`)
  rather than scheduler-dependent.

The gate release moved into `finally`, ahead of disposal: `DisposeAsync` awaits the
dispatch loop, which is now genuinely parked on that gate, so a failed assertion
would otherwise hang instead of failing.

Verified: 15/15 clean under the exact contention the issue measured (Runtime +
AdminUI + Core concurrently) — where the half-fixed version still failed on run 4.
Falsifiability: restoring the async-void handler turns the new assertions red.
2026-07-26 10:29:27 -04:00
Joseph Doherty e3155fbbf5 merge: never slice a DB-sourced string with the range operator (#504)
v2-ci / build (push) Successful in 4m11s
v2-ci / unit-tests (push) Failing after 14m22s
`@s.SourceHash[..12]` on an unvalidated nvarchar(64) threw out of
BuildRenderTree and took the whole /scripts page to an HTTP 500. Replaced with a
shared, null/short-safe `DisplayText.Abbreviate`, applied to all five unguarded
range-operator slices over DB-sourced strings in the AdminUI.

Live-verified on docker-dev: /scripts 500 -> 200 with the short hashes rendered
in full; long hashes still truncate with an ellipsis on the other pages.
2026-07-26 09:43:55 -04:00
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 d5bd4226ee docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete
Task 26 — the P2 milestone gate. Ran the live /run verification on an isolated
docker-dev rig (project otopcua-mqtt, ports 9210/4850-4851/14350, image built
from this branch) against the real Mosquitto TLS+auth broker and the C#
Sparkplug edge-node simulator on 10.100.0.35, and recorded the result.

The gate found three defects. Two are the same defect class the P1 gate found
twice — a hand-maintained AdminUI surface left behind by a driver-side feature —
and the third is a pre-existing cross-driver bug that only a Sparkplug flow
could surface.

  1. CRITICAL, FIXED — MqttDriverForm still shipped its P1 Sparkplug PLACEHOLDER.
     Switching Mode to SparkplugB rendered a "not available yet" notice and NO
     Group ID field, so with Sparkplug ingest fully shipped there was still no
     way to author a Sparkplug driver from the AdminUI at all. Sparkplug.GroupId
     is the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒
     connected, Healthy, ingesting nothing. Now authors all five Sparkplug keys,
     MERGES over the existing sub-object rather than replacing it (so a key a
     newer driver adds inside it survives an older AdminUI), leaves it untouched
     in Plain mode, and validates the group id as the topic segment it is.
     Pinned by 8 MqttDriverFormModelTests cases, verified falsifiable — 8 RED
     with the fix stubbed out.

  2. PRE-EXISTING + CROSS-DRIVER, FIXED — every node label in the shared
     DriverBrowseTree was an <a href="#">. In a Blazor Web App, blazor.web.js's
     enhanced-navigation click interceptor resolves a bare "#" against
     <base href="/">, so clicking ANY browse-tree node label navigated the whole
     AdminUI to "/", tore down the circuit, and destroyed the hosting modal —
     losing the browse session AND the tag selection. @onclick:preventDefault
     does not help: it suppresses the browser's default action, not Blazor's own
     interceptor. Labels are now <button type="button">. Dates to the component's
     introduction (2026-05-28) and affects EVERY driver's picker; it surfaced
     only now because choosing a rebirth scope is the first flow that requires
     clicking a label rather than the ▶ toggle (always a button, always fine).

  3. FIXED (mitigation) — the browse tree rendered exactly once, at open, and
     never again, while OpenAsync returns as soon as the SUBSCRIBE is granted.
     On Sparkplug that means it is almost always empty forever, because births
     are never retained. Added a Refresh button that re-reads the root against
     the SAME session (nothing reconnects, nothing observed is lost, the tag
     selection is kept, only the armed rebirth scope is cleared).

Live gate result (all against the real broker + simulator, group OtOpcUaSim):

  - driver authored end-to-end through the fixed MqttDriverForm; blob is
    Mode:SparkplugB + Sparkplug.GroupId:OtOpcUaSim with enums as names
  - browse: BROWSER OPEN chip, Sparkplug-only rebirth panel, tree renders
    Group → EdgeNode → [Device] → Metric with the device folder Filler1 and
    node-level metric leaves SIDE BY SIDE, and "Node Control/Rebirth" as ONE
    leaf (the metric-name-is-not-a-topic-segment rule holding)
  - rebirth: group scope names the group + the 32-node cap, Cancel published
    NOTHING (simulator log unchanged); edge-node scope → "1 command published"
    and the simulator logged "rebirth NCMD #1 received; republishing metadata";
    group scope → "2 commands published", both edge nodes re-birthed; a device
    and a metric both resolve UP to "edge node EdgeA"; the panel does NOT render
    for a Plain MQTT device
  - browse-commit wrote bindable tuples with no topic/address key —
    {"groupId","edgeNodeId","metricName","deviceId"} for the device metric and
    deviceId ABSENT (not blank) for the node-level one, datatypes inherited from
    the birth (Int64 / Float)
  - deployed (Sealed), and both nodes served live changing values through OPC UA
    at the 2 s cadence — Good 0x00000000, no BadNodeIdUnknown
  - death→STALE: stopping the simulator drove both nodes to 0x80000000 with an
    empty value ~2 s later, and restarting it recovered them to Good on the new
    birth (FillCount back to its birth-declared 1000, then counting)
  - rediscovery fired exactly TWICE (once per authored scope: OtOpcUaSim/EdgeA
    7 metrics, OtOpcUaSim/EdgeA/Filler1 3 metrics) and was then gated across
    many subsequent births/rebirths — the anti-storm change gate working, and
    EdgeB correctly filtered out as an unauthored scope
  - tag editor: opens in SparkplugB (mode inference on reopen) with all four
    descriptor fields populated; blank group → "A Sparkplug group ID is
    required."; "Plant/1" rejected; "Node Control/Rebirth" ACCEPTED and saved
    with the slash whole; dataType override persists and its key is ABSENT again
    after selecting "(from birth certificate)"

Two things are NOT verified, and are recorded rather than claimed:

  - Rediscovery is INERT in v3 and this is a platform gap, not an MQTT one:
    nothing subscribes to IRediscoverable.OnRediscoveryNeeded and
    DriverHostActor.HandleDiscoveredNodes hard-returns. A DBIRTH introducing a
    metric does NOT change the OPC UA tree; redeploy. Verified from the driver's
    log line only, exactly as far as it can be verified.
  - A metric name containing "/" cannot be BROWSE-COMMITTED: the derived raw tag
    Name is a RawPath segment and may not contain "/", so the spec-mandatory
    "Node Control/Rebirth" is refused ("Row 3: Name must not contain '/'"). The
    refusal is loud and all-or-nothing, never a silently mis-bound tag, and
    Manual entry is the working path. Fixing it needs a name-sanitisation policy
    with a collision answer, so it was recorded rather than guessed at.

Also left open + documented: an empty browse tree cannot arm a rebirth (the
scope comes from a tree click, and the session side already accepts a bare
{group}/{edgeNode} — only a UI path is missing); _canRebirth is captured at
browse-open; the tag editor injects the Plain-only payloadFormat key into a
Sparkplug blob (cosmetic — the factory routes on the tuple).

Suites: offline 1528 passed / 0 failed (581 Driver.Mqtt + 809 AdminUI + 138
Core.Abstractions); live 15/15 against the broker + simulator.

Docs: docs/drivers/Mqtt.md brought fully up to date for P2 (Sparkplug config
sub-object, both tag shapes, the ingest state machine, rediscovery, browse +
rebirth + Refresh, a Known-gaps table); Mqtt-Test-Fixture.md gains the simulator
and drops its "Sparkplug has no fixture" claims; infra/README.md §3 gains the
simulator row; CLAUDE.md gains the simulator endpoint facts; the tracking doc
marks MQTT/Sparkplug COMPLETE. docker-dev/docker-compose.mqtt.yml is the
isolated-rig overlay the gate ran on.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-25 01:11:56 -04:00
Joseph Doherty 8d9155682d fix(mqtt): browse-commit writes a bindable address + a Request-rebirth affordance
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.

Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.

The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.

Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.

Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 23:43:07 -04:00
Joseph Doherty 767e7031b3 test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix
Adds a project-owned, controllable Sparkplug B edge node (SparkplugEdgeNode) that
encodes with the SAME generated Tahu schema the driver decodes with — so the live
gate checks encode/decode symmetry rather than the driver against itself — and
drives the §3.6 matrix end-to-end over the real TLS+auth Mosquitto fixture.

The simulator frames its topics independently of SparkplugTopic.Format: a
simulator borrowing the parser's formatter could not detect a bug in it, because
both sides of the comparison would be wrong together. It registers a real NDEATH
Last Will at CONNECT, restarts seq at 0 on NBIRTH, publishes DATA metrics as
alias-only, carries bdSeq per session, and encodes signed ints as two's
complement in the unsigned proto field.

SparkplugLiveTests (Category=LiveIntegration, env-gated, skip-clean) covers:
birth -> alias-only data -> OnDataChange under the RawPath; late-join rebirth
NCMD honoured by an independent decoder; alias reuse across a rebirth routing by
metric NAME; a stale-bdSeq NDEATH ignored while the current one stales; the real
broker-published Will staling and the next birth restoring; seq wrap 255->0
requesting no rebirth while a deliberate gap does; and the browser's passive
window asserted ON THE WIRE (a third client watching spBv1.0/{group}/NCMD/#),
plus RequestRebirthAsync node-vs-group enumeration.

Two things make the suite falsifiable rather than decorative: the seq-wrap test
builds its ingestor with rebirthDebounce: TimeSpan.Zero (at the shipped 10s
default a wrap-triggered NCMD would be swallowed and the test would pass for the
wrong reason) and pairs the silence with a deliberate gap that must produce one;
and the passivity assertion observes the broker rather than the session's own
publish counter, which can only see the seam it guards.

Docker/docker-compose.yml gains a profile-gated `sparkplug-sim` service running
the same engine standalone against the same broker (TLS, CA-pinned) — a manual
driving aid for the AdminUI picker, deliberately NOT a test dependency, since the
matrix needs an edge node it can command mid-test. Its app directory is publish
output (publish-simulator.sh), gitignored like secrets/.

Offline: 15 skipped, 0 failed with no env set. Live: 15/15 passed against
10.100.0.35:8883. Falsifiability spot-check: mutating AliasTable.RebuildFromBirth
to merge instead of replace, and neutering SparkplugCodec.ReinterpretSigned for
Int32, turned exactly the two corresponding tests red and nothing else; both
mutations reverted.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 23:18:08 -04:00
Joseph Doherty 64ec7aa43a feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action
Unseals the Sparkplug branch of the MQTT address-picker browser that Task 10
deliberately sealed shut, and adds the first (and only) sanctioned publish on a
browse session.

- MqttBrowseSession serves Group -> EdgeNode -> [Device] -> Metric in Sparkplug
  mode, decoded from observed NBIRTH/DBIRTH certificates (the only Sparkplug
  messages that name their metrics). Node-level metrics hang directly under
  their edge node -- no synthesised device folder, because the authored address
  tuple genuinely has deviceId = null for them. Metric node ids use a '::'
  separator: a metric name may contain '/', so slash-joining would make a
  node-level metric indistinguishable from a device folder.
- AttributesAsync is purely birth-derived in Sparkplug mode. The plain path's
  UTF-8 inference / payload-snippet machinery does not run and no payload bytes
  are retained: a Sparkplug body is protobuf and would be reported as
  "(N bytes, binary)" for every metric in the plant. A datatype ToDriverDataType
  cannot map is reported as the Sparkplug type name, never coerced.
- RequestRebirthAsync(scope) is the one publishing member, reached only by an
  explicit operator action. It routes through the counted PublishAsync
  chokepoint via a private RebirthTransport adapter, so PublishCountForTest
  still proves that OpenAsync/RootAsync/ExpandAsync/AttributesAsync/Observe/
  DisposeAsync publish nothing -- now in both modes. A device or metric scope
  resolves up to its owning edge node (NCMD is node-addressed); group scope
  enumerates observed edge nodes and is refused whole past
  MaxGroupRebirthNodes = 32.
- BrowserSessionService.RequestRebirthAsync gates on the same DriverOperator
  policy that gates the picker, evaluated server-side where the action happens
  rather than only at render time, fails closed, and Info-logs the scope and the
  published count. Exposed through the new IRebirthCapableBrowseSession contract
  so "does this session write?" stays a type question.
- ToBrowseOptions' Last-Will landmine re-verified: MqttDriverOptions still
  carries nothing will-shaped (an NDEATH is a broker-published will and would be
  invisible to PublishCountForTest), now pinned by a reflection guard.

Falsifiability: injecting a publish into RootAsync reddens the Sparkplug and
plain passivity tests; double-publishing per target reddens all four
one-NCMD-per-node assertions. 572/572 MQTT tests, 24/24 AdminUI browsing tests.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 22:51:16 -04:00
Joseph Doherty 6d7a458c4d fix(mqtt): reset Sparkplug ingest state at every session/authoring seam
Task 21 review — two Criticals sharing one root cause, plus four follow-ups.

C1/C2 root cause: `OnReconnectedAsync` opened with `Births.Clear(); _trackers.Clear();`
— "not housekeeping, the correctness step" — and that reset was missing from the
other two seams where the view underneath a surviving cache changes.

- C1: the ingestor OUTLIVES a session rebuild. `SessionIdentity` is a strict
  superset of `IngestIdentity`, so changing only Host/Port/ClientId/credentials/
  TLS/a timeout gives `!SameSession && SameIngest` — `ReinitializeAsync` tears the
  session down and re-establishes on the same instance, and `TeardownAsync` touches
  no ingest state (the resilience layer re-running `InitializeAsync` is the second
  path). A node that rebound alias 5 during the changeover would publish Pressure's
  value under the Temperature RawPath at Good. Hoisted into `ResetSessionState`,
  called from `AttachTo` (earliest seam — a persistent session can push between
  CONNACK and our SUBSCRIBE), `EstablishAsync` and `OnReconnectedAsync`.
- C2: `Register` pruned trackers but not births. While a node is unauthored its
  traffic is dropped at `Dispatch`, so its cached birth FREEZES rather than
  refreshing; drop-then-re-add across a week mis-routes at Good, and `Births` grew
  monotonically. Now evicted on edge-node departure.
  Deliberately NARROWER than "absent from ByScope": a device with no authored tags
  under a still-authored node keeps receiving traffic, so its birth is live, not
  frozen — evicting it would discard correct state. Pinned both ways.

- I1: a birth whose seq is unreadable produced a permanent debounce-paced rebirth
  loop. "A birth arrived" is now tracked separately from "the birth established a
  baseline". Writing the test showed the cap belonged wider than the review scoped
  it: data-before-birth and unknown-alias loop identically (20 messages → 23 NCMDs
  against a cap of 3), so all three missing-metadata reasons share one per-node
  consecutive cap, re-armed by any applied birth. A seq gap stays uncapped — it
  self-limits.
- I2: the oversize `WarnOnce` key was the raw topic, off a group-wide `#`
  subscription. `HandleMessage` now parses and filters to authored nodes BEFORE the
  size check (also skipping the protobuf parse for discarded traffic), and `_warned`
  carries a hard ceiling so a future bad derivation degrades to silence.
- I3: array metrics slipped the unsupported-type gate — `ToDriverDataType` returns
  the ELEMENT type, so a String-typed tag published a packed `byte[]` as base64 at
  Good. Gated on `IsSparkplugArray`.
- I4: driver-level Sparkplug coverage for the composition-root lines T22 left
  untested — the `OnDataChange` re-raise, `ReadAsync` off the shared cache,
  subscribe/unsubscribe dispatch, and both directions of the mode gate.

Minors: M1 the oversize test fed unparseable bytes and passed with the size check
deleted (now a real NBIRTH + an at-the-ceiling control); M2 answered in-place (an
absent seq is refused WITHOUT becoming the baseline, so the NCMD assertion is the
complete check); M3 `HostStateObserved` raise wrapped; M5 the legacy `STATE/{host}`
form is now subscribed, so the parser's tolerance is reachable in production; M8
`ResolveBinding` prefers the NAME over a disagreeing alias; M9 a narrowing float
conversion refuses instead of publishing ±Infinity at Good (a publisher's own
infinity still passes through). M4 was already resolved by T22.

Also fixed a race in the new tests: `publish.Topics.Clear()` after a call that
dispatches rebirths off-thread must drain first — it made the C1 sequence-baseline
pin pass vacuously in the first falsifiability run.

Falsifiability: dropping the reset from AttachTo+EstablishAsync reddens 4 (C1);
dropping the Register eviction reddens 2 (C2); widening the eviction predicate to
the reviewer's literal phrasing reddens the keep-live-births pin (C2b). 545/545 MQTT
unit tests green (was 524); whole-solution `--no-incremental` build clean under TWAE.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 22:44:43 -04:00
Joseph Doherty cd0157a3b8 feat(mqtt): typed tag editor Sparkplug mode + validation
Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the
"not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/
metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig
in both directions -- required ids + optional deviceId, dataType/qos read
strictly but only when present (dataType is optional: the birth certificate
declares it). One rule is deliberately stricter than the factory: group/edge-
node/device ids reject '/', '+', '#' (topic-segment characters a decoded
incoming id can never carry, so an authored one that does can never bind);
metricName is exempt since Sparkplug's own canonical names use '/' (e.g.
"Node Control/Rebirth"). No FullName key is written -- TagConfig carries no
identity under v3, and the plan's snippet asking for one was corrected per the
brief. A SparkplugB tag now survives save/reopen because its descriptor keys
re-infer Mode on load; there was never a 'mode' key to persist.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 22:43:44 -04:00
Joseph Doherty 6917d8805d feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH
RediscoverPolicy is now mode-dependent: UntilStable in Sparkplug B (a tag's
dataType is optional -- the birth certificate declares it, so the discovered
tree fills in asynchronously), Once in Plain (nothing about the authored set
arrives on the wire).

DiscoverAsync resolves each Sparkplug tag's datatype per pass from the live
BirthCache, with the ingest path's own precedence: authored dataType wins,
else the birth's, else the record default. An unsupported Sparkplug type
(DataSet/Template/PropertySet/Unknown) falls back rather than blanking the
tag. The authored tag SET is never conditional on a birth -- an authored tag
is part of the declared configuration, not something the plant grants by
publishing.

SparkplugIngestor.BirthObserved is wired to RaiseRediscoveryNeeded behind a
two-part change gate, which is the anti-storm mechanism rather than an
optimisation: fire only for a scope an authored tag binds, and only when the
birth's metric-name SET (ordered-distinct, ordinal) differs from the last one
seen for that scope. Edge nodes re-birth freely -- on their own timer, on
every reconnect via the late-join rebirth fan-out, and once per rebirth NCMD
the gap policy sends -- and firing on each of those would make a healthy plant
a permanent address-space-rebuild loop. No extra debounce: what survives the
gate is a real edit to the served tree, and delaying it would need its own
trailing-edge flush to avoid dropping the last change.

The signature map is NOT cleared on death/reconnect/ingest rebuild (the
ingestor drops its whole birth cache on reconnect, but "the driver forgot" is
not "the address space changed"); it is pruned when a redeploy stops
authoring a scope.

ScopeHint is the "Mqtt" discovery folder, deliberately not the Sparkplug scope
path the plan named: the discovered tree is flat, so an edge-node/device path
would name a subtree that does not exist and a consumer scoping a rebuild on
it would rebuild nothing. The scope is carried in Reason instead.

Falsifiability: always-fire reddens the identical-rebirth + reordered-rebirth
pins; always-authored-datatype reddens the birth-fill pin; dropping the
authored-scope filter reddens the unauthored-device pin. 524/524 MQTT unit
tests green (was 513).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 22:28:08 -04:00
Joseph Doherty 8538fb798b feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)
Task 21 — the design doc's §3.6 correctness core. `SparkplugIngestor` owns the
authored `(group, node, device?, metric) → RawPath` index, the live `BirthCache`,
one `SequenceTracker` per authored edge node, and the rebirth policy; it routes
NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/STATE into `OnDataChange` +
`LastValueCache`, or into a bounded, per-node-debounced rebirth NCMD.

Invariants held (and each pinned by a falsifiability control):
- Published reference is the RawPath, never a composed Sparkplug reference —
  `DriverHostActor`'s dual-namespace fan-out is RawPath-keyed.
- Tags bind by stable metric NAME; the alias is only the per-birth lookup, so an
  alias reused across a rebirth cannot mis-route.
- Every value goes through `SparkplugMetricBinding.Reinterpret` before publish.
- NDEATH never reaches `SequenceTracker.Accept` (it carries no seq); it is tied to
  its birth by bdSeq instead.
- An invalid payload mutates nothing — no tracker, no alias table, no eviction.
- `HandleMessage` never throws on MQTTnet's dispatcher thread.

Supporting changes:
- `MqttTagDefinitionFactory.FromSparkplugTagConfig` — the driver had no way to
  parse the Sparkplug descriptor keys at all; the P1 stub fields on
  `MqttTagDefinition` were never populated. Mode selects the parser, never a
  blob heuristic. `dataType` becomes optional in the Sparkplug shape (the birth
  declares it), recorded via the new `DataTypeAuthored` flag.
- `MqttConnection` implements `IMqttPublishTransport` (bounded, refusal throws).
- `MqttDriver` dispatches every capability by mode, subscribes `spBv1.0/{group}/#`
  (+ the configured STATE topic) once at connect, and re-subscribes + requests a
  late-join rebirth on reconnect. `IngestIdentity` now names `MqttSparkplugOptions`,
  closing the silent same-ingest gap its own ⚠️ comment warned about.

Primary-host STATE *publishing* is explicitly out of scope and warned about at
construction: it needs the retained-OFFLINE Last Will set at CONNECT time, and
shipping the ONLINE half alone is worse than shipping neither.

58 new tests (MQTT suite 455 → 513, all green).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 22:11:41 -04:00
Joseph Doherty 31a98a1bf5 feat(mqtt): RebirthRequester NCMD encode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:40:43 -04:00
Joseph Doherty 3fdf24659f fix(mqtt): AcceptBirth is NBIRTH-only -- a DBIRTH routed there wipes bdSeq
Self-review of the commit before this one caught a defect in the contract it
published rather than in the code it ran. AcceptBirth's doc said it records
"a birth (NBIRTH/DBIRTH)". That is wrong on both halves of this type, and the
wrong version plants exactly the defect the bdSeq tie exists to prevent.

Only the NBIRTH restarts the Sparkplug sequence; a DBIRTH carries the next
number in the edge node's ongoing stream, so it belongs to Accept. And only
the NBIRTH and NDEATH carry bdSeq at all -- a DBIRTH has none to pass. So a
Task 21 that followed the old doc and routed a DBIRTH to AcceptBirth would do
two wrong things at once: rebase the sequence mid-stream, and wipe the node's
session token to null. A stale Last Will arriving afterwards would then find
nothing to compare against, fall through the deliberate fail-toward-stale
rule in IsDeathForCurrentSession, and kill the live node.

Renamed AcceptBirth -> AcceptNodeBirth so the DBIRTH call site reads wrong at
a glance rather than only in prose, documented the hazard on both methods,
and added DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone
to pin the shape at this type's own boundary -- the ingest state machine now
has something to fail against if it wires the call sites the other way round.

Falsifiability: mutating Accept to clear _birthBdSeq reddens exactly the new
test (1 RED), reverted. 29 tests, 438/438 for the MQTT suite.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:32:44 -04:00
Joseph Doherty e4e313cf1c feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie
SequenceTracker (Driver.Mqtt/Sparkplug/) holds one edge node's Sparkplug
stream-continuity state -- design doc SS3.6 invariant #3. Two independent
halves: the wrapping 0-255 seq counter that reports whether a message was
missed, and the bdSeq session token that ties an NDEATH to the NBIRTH it
belongs to.

Next-expected is (last + 1) & 0xFF, so 255 -> 0 is the sequence continuing.
The boundary is the whole task because it fails differently in each
direction: read the wrap as a gap and every edge node is asked to rebirth
once per 256 messages, so a busy node spends its life republishing births
instead of data; miss a real gap and the driver serves values it knows are
behind the device, at Good quality, indefinitely. Both directions are
asserted, plus two full wrap cycles so an off-by-one that only misfires at
one value has nowhere to hide.

A gap is reported once and the tracker then resynchronizes onto the observed
value -- holding the baseline at the pre-gap number would make every
following message report a gap too, so one lost message would become a
permanent rebirth storm, the same pathology as a wrong wrap reached by a
different route.

An out-of-range seq is refused, never masked. The codec hands seq over as a
ulong precisely so a spec-violating publisher's value is not truncated into a
plausible-looking one, and masking would finish the job it declined to do:
with the baseline at 255, a seq of 256 masks to 0 -- exactly what the wrap
expects next -- and the bogus message would be accepted as contiguous.
Refused values do not become the baseline either, so the next genuine message
is still measured against the last credible one.

A birth restarts the sequence via AcceptBirth rather than Accept, so it is
never itself a gap; a gap after a birth is still detected. An in-range but
non-zero birth seq is adopted rather than refused -- refusing it would demand
a fresh rebirth for every message from an otherwise-followable publisher. The
first message on a virgin tracker cannot be a gap (nothing to measure it
against) but leaves IsBirthSynchronized false, which is how Task 21 tells a
mid-stream late join from a synchronized node.

bdSeq exists to stop a late Last Will from killing a live node: a node's
connection drops, it reconnects and publishes a fresh NBIRTH, and only then
does the broker notice the old connection died and deliver its will carrying
the PREVIOUS session's bdSeq. Without the compare that stale NDEATH drives
every tag of a freshly-born healthy node to Bad. Unknowns fail toward stale
deliberately -- only a positive mismatch of two known tokens discards a
death, because acting on a death wrongly self-corrects at the next birth
(invariant #4) while ignoring a real one leaves a dead node's values flowing
Good forever. bdSeq is not a top-level field, so TryReadBdSeq extracts it
from the metric named bdSeq and runs it through ReinterpretSigned first: read
raw, a negative Int64 bdSeq becomes an enormous ulong -- a plausible-looking
session token minted out of nonsense.

Falsifiability verified, each mutation reverted after observing RED:
(a) no-wrap expected -> 2 RED; (b) Accept always true -> 5 RED; (c) bdSeq
compare always matches -> 2 RED; (d) mask out-of-range seq into range ->
2 RED. 28 tests, 437/437 for the MQTT suite.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:29:08 -04:00
Joseph Doherty 2afef00eaa feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth
Design §3.6 invariants #1/#2. A Sparkplug alias is scoped to ONE birth: after a
rebirth an edge node may point alias 5 at a different metric. So authored tags
bind by the stable metric NAME and the alias is a per-birth cache only —
RebuildFromBirth builds both indexes fresh and publishes them in one assignment,
never merging or upserting, including for an empty birth (which legitimately
clears the scope).

BirthCache keys scopes by the full (group, edgeNode, device?) triple and applies
the spec's node→device rules: an NBIRTH invalidates every DBIRTH under that node
(returning their metric sets for the STALE fan-out), a death evicts rather than
blanks so data-before-rebirth stays detectable. Reads are lock-free on MQTTnet's
dispatcher thread — an immutable snapshot swapped atomically per scope, the same
discipline MqttSubscriptionManager.AuthoredTable uses, with rebuilds landing in
place so a held table reference always observes the newest birth.

Running values are deliberately NOT stored here — LastValueCache owns those,
keyed by RawPath. SparkplugMetricBinding.Reinterpret exposes the birth datatype
to the codec's two's-complement fix, since a DATA metric carries no datatype.

Falsifiability: mutating RebuildFromBirth to merge reddened 5/19 tests (both
plan invariants); making the alias carry metric identity across a birth reddened
3/19, including the headline alias-reuse case.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:28:41 -04:00
Joseph Doherty b245f380b1 feat(mqtt): Sparkplug topic parse/format + datatype map
SparkplugTopic (Driver.Mqtt/Sparkplug/) parses/formats spBv1.0 topics for
every message type (NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE).
TryParse never throws -- it is fed every topic a live spBv1.0/{group}/#
subscription delivers -- and rejects device/node-scope mismatches and MQTT
wildcard chars in a segment. STATE is handled honestly rather than force-fit
into the {group}/{type}/{node} mould: it parses both the v3.0
spBv1.0/STATE/{hostId} form and the legacy no-prefix STATE/{hostId} form
(tolerated on receive only -- Format/FormatState always emit v3.0). Format
gives Task 20's NCMD/DCMD write path a builder instead of hand-concatenation.

SparkplugDataType is a `global using` alias for the vendored proto's
generated Org.Eclipse.Tahu.Protobuf.DataType, not a second hand-duplicated
enum -- Metric.Datatype is a raw wire uint32 (no enum-typed field forces a
second CLR type to exist), and SparkplugCodec (Task 16, landed concurrently)
already casts straight to the generated type. A duplicate enum would be the
same enum-drift hazard this repo already names systemic (CLAUDE.md's driver
enum-serialization bug) and would force every downstream task to cast
between two value-compatible-but-nominally-different enums. ToDriverDataType()
maps per design doc SS3.5: Int8/UInt8 widen to Int16/UInt16 (no 8-bit
DriverDataType member), Float/Double to Float32/Float64 (there is no
DriverDataType.Double), Text/UUID/Bytes/File to String, *Array variants to
their element type (IsSparkplugArray carries the array bit separately), and
DataSet/Template/PropertySet/PropertySetList/Unknown return null -- an
explicit "unsupported, caller must skip+warn" rather than a guessed String.

The completeness test enumerates the live generated DataType member set
(via the alias) and asserts every member is mapped or on the explicit
unsupported list, so a future Tahu proto change is caught automatically
instead of silently falling through a stale duplicate enum's default.
Falsifiability verified by hand for three defect shapes (each reverted after
observing RED): wrong Int8 widening, a dropped mapping falling through
undetected, and inverted node/device topic scoping.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:16:02 -04:00
Joseph Doherty c164ec3da1 fix(mqtt): SparkplugCodec's never-throw guard must span the projection too
The try/catch stopped at Payload.Parser.ParseFrom, leaving the metric-projection
loop outside it — so the "never throws for any input" contract had a hole in
exactly the part that walks an attacker-shaped object graph. Widened to cover
parse AND projection.

Self-review finding; no test reddened, because a projection escape needs a
generated-code shape the vectors do not produce. That is the point: the guard is
the contract, not the observed behaviour of today's inputs.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:10:20 -04:00
Joseph Doherty 2589774480 feat(mqtt): SparkplugCodec decode + golden payload vectors
Decodes Sparkplug-B wire bytes into a driver-side projection (SparkplugPayload /
SparkplugMetric / SparkplugValueKind) for the Task 21 ingest state machine.

- Never throws, for ANY input. It sits on MQTTnet's shared dispatcher thread, so
  an escaping exception would stall delivery for every subscription on the
  connection, not one tag. Garbage, truncation, zero-length, a valid protobuf of
  another schema and an over-nested Template all resolve to a verdict.
- Zero-length input is INVALID, not "a payload with no metrics" — protobuf would
  parse it as all-defaults, and that is how a truncated-to-nothing body gets
  mistaken for a well-formed one.
- Explicit presence throughout (Has{Seq,Name,Alias,Datatype,Timestamp}), never a
  zero-check: an NBIRTH legitimately carries seq = 0, and every DATA metric after
  a birth carries an alias with no name and no datatype.
- Values are projected RAW, boxed, with the value oneof reported as an explicit
  ValueKind — Absent / Null / Scalar / Unsupported all mean different things and
  three of them carry a null value. DataSet/Template/extension decode as
  Unsupported (v1 scope) rather than throwing or silently vanishing.
- ReinterpretSigned() undoes Sparkplug's two's-complement-in-an-unsigned-field
  encoding of Int8/16/32/64. Kept out of decode because a DATA metric carries no
  datatype — only the consumer, holding the birth's alias table, knows which
  applies. Skipping it publishes 4294967254 for a tag whose value is -42.
- Datatype is carried as the generated Org.Eclipse.Tahu.Protobuf.DataType; the
  SparkplugDataType map is Task 17's and is applied downstream (Task 21).

Golden vectors: nbirth.bin (seq=0, named/aliased catalog, a negative Int32, an
is_null metric, a DataSet metric) + ndata.bin (alias-only metrics, no name, no
datatype) are hand-built by SparkplugGoldenPayloads, committed, and pinned by a
drift guard that rebuilds and byte-compares them on every run — plus a test that
they actually reach the output directory, since a missing copy item is invisible
in source.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:09:14 -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 163dd7ab5c fix(mqtt): unbreak the .Contracts csproj -- '--' is illegal in an XML comment
The build-platform note added alongside the proto wiring quoted docker-dev's
`FROM --platform=linux/amd64` verbatim inside an MSBuild comment. XML forbids
'--' in a comment, so MSBuild refused to load the project at all (MSB4025) --
a total build stop for every consumer of .Contracts, i.e. the whole MQTT
driver, the Host, and the test suite. Reworded to name the platform in prose.

Caught by rebuilding after the edit; the preceding commit was made from a
build that predated it.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:56:20 -04:00
Joseph Doherty 1ae26675ac feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen
Vendors the NORMATIVE (proto2) Eclipse Tahu sparkplug_b.proto and compiles it
in .Contracts with build-time Grpc.Tools, message-only (GrpcServices="None") --
Sparkplug rides MQTT, there is no gRPC service, so no Grpc.Core.Api is pulled in.

Provenance rides in the file header: repo, path, pinned commit
5736e404889d4b95910613040a99ba79589ffb13, permalink, git blob SHA-1
bf72ab5f..., content SHA-256 4432c5c4..., EPL-2.0, and a re-verify recipe. The
body below line 38 is byte-for-byte upstream; the blob SHA-1 matches the tree
entry at that commit, so the copy is provably genuine and not reconstructed.

Chose the proto2 file over upstream's sibling sparkplug_b_c_sharp.proto: the
sibling is a lossy proto3 restatement that drops explicit presence, and protoc
generates valid C# from proto2 into the identical namespace
(Org.Eclipse.Tahu.Protobuf, PascalCased from the package -- no
csharp_namespace option). Presence matters downstream: an NBIRTH legitimately
carries seq = 0 and a DATA metric legitimately omits `name`, so Has{Seq,Name,
Alias,IsNull,Datatype} is the difference between "absent" and "present, zero".

.Contracts stays transport-free: Google.Protobuf is a serialization dependency
with a framework-only graph, Grpc.Tools is PrivateAssets=all, and the resolved
graph is exactly {Google.Protobuf, Grpc.Tools, Core.Abstractions} -- no MQTTnet.

Codegen tests pin the round-trip, the namespace, the Sparkplug field numbers,
the DataType spec indices, and protoc's enum-name mangling (UInt64 -> Uint64,
UUID -> Uuid) that Task 17's datatype map has to spell correctly.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:54:23 -04:00
Joseph Doherty 92ba120964 feat(mqtt): MqttDriverForm + MqttDeviceForm — the driver/device config forms P1 omitted
The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and
DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON
fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built
the *tag* editor; these are the driver + device surfaces.

MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId,
TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect
backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 —
a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys
preserved untouched.

The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and
contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up
only when the driver has exactly ONE device, so a device-authored broker connection vanishes
silently the moment a second device is added. MqttDeviceForm is therefore informational (the
GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys
left on a device by a pre-form deployment, because those still win via merge-up.

Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a
fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted
blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's
serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping
the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green —
the Task 9 lesson, reproduced.

RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level
keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and
ToOptions() additionally clamps, so an ignored error still cannot persist a
connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning
inline (fixed ids make redundant pair nodes evict each other while both report Healthy).

AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:41:48 -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 07a4a5ff5e docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded
Task 14 — the P1 milestone gate. Ran the live /run verification on a docker-dev
rig against the real Mosquitto TLS+auth fixture, and recorded the result.

The gate did its job: it found MQTT was unauthorable in production.

  RawDriverTypeDialog's driver-type list is a hand-maintained array that nobody
  added MQTT to, so the /raw "New driver" picker never offered it — with every
  other layer (contracts, driver, browser, factory, host registration, typed tag
  editor) complete and 266 green unit tests. Nothing tied that array to
  DriverTypeNames and AdminUI has no bUnit, so no offline test could see it.
  Fixed, plus RawDriverTypeDialogParityTests: a reflection parity guard that
  fails for ANY DriverTypeNames entry missing from the picker. Verified
  falsifiable — RED with "…cannot be authored from the /raw New-driver picker,
  so they are unreachable in production: Mqtt" before the one-line fix.

A second gap is left OPEN and documented, not fixed (no task ever covered it):
MqttDriverForm / MqttDeviceForm do not exist, and DriverConfigModal/DeviceModal
have no raw-JSON fallback — so an operator can create an MQTT driver but cannot
author its broker endpoint or credentials. P1 is not operator-complete until
those two razor forms land. The gate authored config via SQL to get past it.

Live gate result (isolated 2-node MAIN stack, image built from this branch,
fixture at 10.100.0.35:8883, AllowUntrustedServerCertificate rather than
pinning the CA into the rig):

  - typed MQTT tag editor renders and round-trips; Json shows the JSON-path
    field, Raw/Scalar hide it; wildcard topic a/+/c blocked at save; data-type
    list offers Float64 and no Double; qos/retainSeed absent on "(driver
    default)" and qos:2 written as a number; isHistorized/historianTagname
    survive a topic-only edit; SparkplugB renders the P2 placeholder and
    switching back keeps Plain values; jsonPath is guidance not a gate in all
    three shapes ($ seeded only for a brand-new blank tag, blank stays absent,
    clearing saves fine)
  - deployed, and both editor-written blobs resolved and served changing live
    values through OPC UA (22.8 / 1629, StatusCode Good) — no BadNodeIdUnknown
  - the bespoke #-observation browser drove a real broker session and rendered
    the fixture's actual topic tree (line1/{counter,state,temperature},
    noise/chatter, retained/seed); a Modbus device's picker still correctly
    reports "Browsing unavailable"

Also found live and documented (not a code change): both nodes of a redundant
pair run the driver, so a FIXED clientId makes them evict each other forever —
the broker logs "already connected, closing old connection" and both nodes
reconnect every ~2s while still reporting Healthy. Unset (the default) is
correct; confirmed 0 reconnects/60s on both nodes after removing it.

Suites: offline 1134 passed / 0 failed (266 Driver.Mqtt + 730 AdminUI incl. the
3 new guards + 138 Core.Abstractions); live 7/7 against the fixture.

Docs: new docs/drivers/Mqtt.md + Mqtt-Test-Fixture.md (the per-driver + fixture
convention every other driver follows), MQTT rows in docs/drivers/README.md,
TestConnectProbes.md, root README.md, and infra/README.md §3.

CLAUDE.md drift corrected while adding the MQTT endpoints:
  - the "every fixture carries project: lmxopcua" claim was false — only the
    MQTT fixture does; the filter returns one stack, not the fleet
  - lmxopcua-fix.ps1 is Windows-VM-only; on macOS drive the host over ssh/rsync
    (and the docker-dev rig itself runs locally under OrbStack)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 18:44:43 -04:00
Joseph Doherty 2409d05a28 fix(mqtt): a broker-refused CONNECT no longer reports a Healthy driver
MQTTnet 5 does not throw on an unsuccessful CONNACK — it returns the reason in
MqttClientConnectResult.ResultCode and v5 removed v4's ThrowOnNonSuccessfulConnectResponse,
so ConnectCoreAsync's unconditional SetState(Connected) sealed a wrong-password deployment
green: DriverState.Healthy / HostState.Running on a session that never authenticated.
Caught by the Task-13 Mosquitto fixture; 240 offline tests missed it.

ConnectCoreAsync now inspects the CONNACK and raises MqttConnectRejectedException.
Credentials/identity/protocol/Last-Will refusals are unrecoverable and set Faulted, which
stops the reconnect supervisor; transient refusals (ServerUnavailable, ServerBusy,
QuotaExceeded, UseAnotherServer, ConnectionRateExceeded, and anything unrecognised) stay
Reconnecting under backoff. MqttDriverProbe now shares the rejection wording, so the
AdminUI Test-connect button and the running driver can no longer disagree.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 18:02:00 -04:00
Joseph Doherty 033b3700c4 test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite
Adds the MQTT driver integration-test project: an eclipse-mosquitto fixture running
auth AND TLS (allow_anonymous false on both listeners), a mosquitto_pub-based JSON
publisher emitting retained messages, a cert/password generator script whose output is
gitignored, and a live suite gated on MQTT_FIXTURE_ENDPOINT that skips clean offline.

The fixture is never anonymous by design: an anonymous broker would let the driver's
TLS/CA-pin and auth paths go untested, and those fail silently. The CA-pin leg carries
its own falsifiability control (a foreign CA generated in-process must be rejected).

Live gate on 10.100.0.35: 7/7 passed. It found a driver defect, reported not fixed
here (src/ is out of this task's scope): MqttConnection.ConnectAsync RETURNS NORMALLY
when Mosquitto rejects a CONNECT as not-authorized -- MQTTnet 5 does not throw on an
unsuccessful CONNACK, so a wrong broker password yields DriverState.Healthy from
InitializeAsync. The auth-negative test therefore asserts the invariant that holds
either way (no session is ever established) and documents the finding.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:49:14 -04:00
Joseph Doherty ff62b62a55 refactor(mqtt): accept a blank jsonPath; guide with a seeded "$" instead
Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under
a Json payload: the runtime defaults it to the document root, which is the real
"publisher puts a bare JSON scalar on the topic" case, so rejecting it made the
editor refuse what the driver accepts — inverting "authoring surface accepts <=>
publish accepts" — and blocked whole CSV-import batches, since this validator
also gates RawManualTagEntryModal's review grid.

Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the
field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an
existing path-less tag keeps the key ABSENT and the driver defaults it. The
wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag
interpretation.

Also: omit a blank "topic" rather than writing "topic":"", and record why the
_lastConfigJson guard diverges from the Modbus template — the landmine is a
DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task
24's Sparkplug field group will be tempted to add more of. Named the host-side
dependency (RawTagModal only mutates through this callback) that keeps the
staleness trade benign today.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:37:13 -04:00
Joseph Doherty a13ae926a8 fix(mqtt): gate DisposeAsync against in-flight lifecycle ops (C1) + rebuild-branch tests (I1)
C1 (Critical) — DisposeAsync went straight to TeardownAsync with no lifecycle-gate
wait, reopening the orphaned-connection class. Interleaving: ReinitializeAsync's
rebuild branch holds the gate mid-InitializeCoreAsync, past its own teardown but
before `_connection = connection`; an ungated dispose sees a null connection, does
nothing, and sets `_disposed`; the rebuild then publishes a live connection plus a
host-probe loop that every later dispose short-circuits past. Not proven reachable
today (DriverInstanceActor.PostStop calls the gated ShutdownAsync), but MqttDriver
publicly implements IAsyncDisposable, which invites `await using`.

- DisposeAsync now takes the gate with the same bounded wait + fallback teardown
  ShutdownAsync uses, and sets `_disposed` BEFORE the wait.
- InitializeCoreAsync re-checks `_disposed` after the connect and disposes the
  connection it just built rather than publishing it — this closes the residual
  window on the bounded-timeout fallback path.
- The doc comment no longer claims parity with ShutdownAsync it did not have, and
  stops conflating "don't Dispose() the semaphore object" with "don't WaitAsync".

I1 (Important) — the SameSession == false rebuild branch had no test driving it.
Adds three: an endpoint-changing delta rebuilds and Faults on a refused connect;
an ingest-only delta (MaxPayloadBytes) ALSO rebuilds, pinning that IngestIdentity
is nested inside SessionIdentity; and DisposeAsync serializes behind a lifecycle
operation parked at a new internal BeforeConnectHookForTests seam (mirroring
MqttConnection's AfterConnectHookForTests, which exists for the same race class).

Minors: comments recording that IngestIdentity names no Sparkplug field and must
grow one in P2 (tasks 21/22), and why _options/_subscriptions/_authoredRawPaths
get looser memory discipline than _health/_hostState.

Falsifiability: reverting DisposeAsync to the ungated form reddens exactly the new
serialization test ("Shouldly.ShouldAssertException : raced"); forcing SameSession
to always-true reddens exactly the two new rebuild tests. 240/240 MQTT tests pass;
forced rebuild of the driver project is 0 warnings under TreatWarningsAsErrors.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:31:04 -04:00
Joseph Doherty f79d13e2d8 feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration
Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).

Carried-forward items:

- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
  MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
  probe's internal JsonOpts and the browser's separate private copy; the factory,
  the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
  it. .Contracts is the only assembly all four consumers reference, and the
  browser's reference to the runtime .Driver project is a documented layering
  exception scheduled for removal — anchoring the options there would resurrect
  the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
  MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
  Ok == false + non-empty message, which is also exactly what a JSON-parse
  failure produces — so it stayed green under the very regression it names.
  It now asserts the probe got past the parse and reached the network.

Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.

Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:24:13 -04:00
Joseph Doherty 36abd871a1 feat(mqtt): typed tag editor + validator (plain mode)
Thin typed model over a preserved JsonObject key bag, mirroring the sibling
<Driver>TagConfigModel template. Key names and strictness rules mirror
MqttTagDefinitionFactory rather than inventing an editor-side schema; enums
round-trip as NAMES (the factory reads them strictly by name).

No FullName key: under v3 a tag is identified by its RawPath, which the factory
keys the definition's Name off — a composed identity key here would be dead
weight nothing reads.

Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug
descriptor key present) and never serialised — the driver takes Plain vs
SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the
Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:17:37 -04:00
Joseph Doherty 420692b6e5 feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)
Composes MqttConnection + MqttSubscriptionManager + LastValueCache into the
IDriver / ITagDiscovery / ISubscribable / IReadable / IHostConnectivityProbe /
IRediscoverable capability set.

- Discovery replays ONLY the authored raw tags (RediscoverPolicy = Once,
  SupportsOnlineDiscovery = false) — a chatty broker can never auto-provision.
- Composition order is register -> AttachTo -> ConnectAsync; the manager's
  Reconnected handler is passed through unwrapped so its throw-on-total-failure
  still tears a deaf session down.
- ReinitializeAsync applies a tag-only delta in place and never faults on a bad
  one; a session-changing delta rebuilds.
- ReadAsync serves the last-value cache and degrades per reference
  (BadWaitingForInitialData), never throwing for the batch.
- FlushOptionalCachesAsync is a no-op: the last-value cache IS IReadable.
- Adds MqttDriverOptions.RawTags (the authored-tag delivery mechanism every
  other driver's options DTO already has) and promotes MaxPayloadBytes from a
  manager ctor knob to an operator-facing key.
- Converges on MqttDriverProbe.JsonOpts rather than a second, divergent
  JsonSerializerOptions.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:06:39 -04:00
Joseph Doherty 211c6ea6d6 feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)
Wires the bespoke MqttDriverBrowser into AdminUI's IDriverBrowser
registrations alongside OpcUaClient/Galaxy, overriding the universal
DiscoveryDriverBrowser fallback for driverType "Mqtt".

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 16:48:55 -04:00
Joseph Doherty a856d61510 feat(mqtt): MqttDriverProbe CONNECT handshake
Test-connect probe for the Mqtt driver type: parses MqttDriverOptions
(shared enum-as-name JsonSerializerOptions), opens a bounded MQTT CONNECT
via MqttConnection.BuildClientOptions with a distinct -probe-{guid8}
client-id suffix, and classifies the outcome. Live-probed against
MQTTnet 5.2.0.1603 to confirm the real contract: a broker-rejected
CONNACK is a MqttClientConnectResult.ResultCode, never a thrown
exception, and timeout classification checks the deadline token itself
rather than switching on exception type (which varies unpredictably
between MqttConnectingFailedException and bare OperationCanceledException
for the same frozen-peer shape).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 16:43:36 -04:00
Joseph Doherty abf3116bd4 fix(mqtt): wildcard tags went dark on reconnect — index by filter, not topic
C1 (CRITICAL). AuthoredTable routed any wildcard-authored tag into `Wildcards`
and never into the concrete topic index. But the two paths that reconstruct
state from a SUBSCRIBED FILTER STRING — OnReconnectedAsync's re-subscribe set
and DegradeTopic — both looked up that concrete-only index, and
`_subscribedTopics` keys on the wildcard PATTERN ("f/+/temp"). So:

- reconnect resolved zero filters for a wildcard tag, hit a silent early
  return, and issued NO subscribe. The cache keeps its last Good value, so the
  tag never turns Bad — it just stops updating forever behind a connection
  reporting healthy. Exactly the silent-death mode MqttConnection's own remarks
  warn about, left unguarded for wildcards.
- DegradeTopic missed too, so a SUBACK rejection of a wildcard filter degraded
  nothing and left the tag at BadWaitingForInitialData.

Fix: AuthoredTable now carries `ByFilter` (EVERY def, keyed by its authored
topic filter, wildcards included) alongside `ByExactTopic` (concrete only) and
`Wildcards`. Delivery matches an incoming published topic — which never carries
a wildcard — so it keeps using ByExactTopic + the comparer scan. Every path
keyed on a filter string uses ByFilter. The distinction is documented on the
record. The silent early return is now a Warning naming the orphaned topics.

Also in this pass:

- I2: bounded decode on the dispatcher thread. A body over `MaxPayloadBytes`
  (default 1 MiB, ctor-settable) is refused BEFORE any GetString/JsonDocument
  parse and degrades its own tags. Unbounded decode on the shared dispatcher is
  paid by every subscription, not just the offending topic. NOTE: promoting this
  to an operator-facing MqttDriverOptions key (+ WithMaximumPacketSize) needs a
  .Contracts edit this task is scoped out of; flagged in the XML docs.
- I3: integral-valued reals now coerce to integer tags. JS/Python edge gateways
  serialize an integer as 5.0, and TryGetInt32/int.TryParse are syntactic — an
  Int32 tag fed by such a gateway silently never received data. Parsed via
  decimal so integrality and range are exact across Int64/UInt64. A genuinely
  fractional 5.5 is still refused, never rounded.
- I4: the JSON document is parsed ONCE per message and shared across the whole
  fan-out (the documented "one document, one JSONPath per signal" shape was
  re-parsing per tag). Zero-alloc via a ref struct when no Json tag matches.
- I5: pinned the documented JSON-string-holding-a-number coercion end-to-end.
- Minor: Register now prunes `_subscribedTopics` / `_handleByRawPath` entries for
  tags a redeploy dropped, so a deleted topic stops being re-subscribed forever.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 16:40:31 -04:00
Joseph Doherty 4ec01bdfc1 fix(mqtt): bound browse observation against a chatty or malicious broker
Three review findings, all "unbounded work/memory from untrusted broker input"
rather than correctness bugs - but this points at a live plant broker.

1. InferPayload decoded the WHOLE payload on MQTTnet's dispatcher thread before
   trimming to 64 chars. The node cap never engages here: a multi-MB blob on an
   already-known topic creates no node, it just updates one, so the cost repeats
   per message forever. Now at most PayloadInspectMaxBytes (512) are examined.
   A blind slice can cut a multi-byte sequence, which the strict decoder would
   report as "binary" - TrimToUtf8Boundary backs the window off to the last whole
   sequence so good UTF-8 is never mislabelled. A clipped payload is String by
   construction rather than inferred from a partial view.

2. The node cap bounded COUNT, not bytes. MQTT topics run to ~65KB, so 50k nodes
   near that ceiling is a multi-GB tree. Topics now bounded at 1024 chars and
   segments at 256, rejected whole (a truncated topic is a DIFFERENT topic the
   picker would commit as a tag address) and surfaced via an __oversized__ marker
   kept distinct from __truncated__ - the operator's remedy differs.

3. Control characters in a snippet would break the picker's single-line row;
   Trim() only strips the ends. Now scrubbed to spaces.

Each guard was falsified independently by neutering it and confirming exactly one
test reddens. That found a real gap: the oversized-topic test was passing via the
SEGMENT bound, leaving the whole-topic bound untested - the test now uses many
short segments so only the topic bound can reject it.

Also records the .Driver project reference as a known, deliberate exception to the
.Browser -> .Contracts pattern, with its cost (AdminUI gains Core + Polly + Serilog)
and the clean fix (a leaf transport project; NOT a move into .Contracts, which is
deliberately transport-free).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 16:30:45 -04:00
Joseph Doherty d421487bcd feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver
MqttSubscriptionManager is the P1 plain-MQTT ingest path: it holds the authored
RawPath → MqttTagDefinition table behind the shared EquipmentTagRefResolver,
establishes one SUBSCRIBE per distinct authored topic, and turns each inbound
message into a LastValueCache update plus an OnDataChange notification.

Load-bearing choices, each pinned by a falsified test:

- The published reference IS the RawPath (def.Name), never the topic and never
  the TagConfig blob — DriverHostActor's dual-namespace fan-out is RawPath-keyed,
  so any other key passes every unit test and delivers nothing in production.
- One message fans out to EVERY tag on its topic; several tags routinely share a
  topic with different jsonPaths, and stopping at the first match silently starves
  the rest.
- An unauthored topic raises nothing: MQTT ingest never auto-provisions.
- Wildcard-authored topics (accepted by the parser, warned at deploy) match via
  MQTTnet's own MqttTopicFilterComparer, so '+'/'#' behave as a broker would.
  Concrete topics stay a single lookup; the wildcard scan runs only when one exists.
- Retained seeds are honoured natively on MQTT 5.0 (retain handling) AND filtered
  client-side on the retain flag, because 3.1.1 brokers always replay.
- Nothing throws on MQTTnet's dispatcher thread: a malformed payload degrades that
  tag (BadDecodingError / BadTypeMismatch), and a throwing OnDataChange subscriber
  is contained without starving the tags behind it.
- The manager issues the FIRST subscribe itself — Reconnected deliberately does not
  fire after the initial ConnectAsync.
- Reconnect re-subscribe: total failure THROWS (MqttConnection then tears the
  session down and retries under backoff, rather than serving a connected-but-deaf
  driver); a partial SUBACK rejection degrades only the denied refs, because
  retrying forever over one ACL-denied topic would take every tag dark.

MqttConnection gains a MessageReceived observer (fired on the dispatcher, throwing
observers contained) and a bounded SubscribeAsync under its own linked-CTS deadline
— deliberately not MqttClientOptions.Timeout, which already governs every MQTTnet
op. A refused filter is an outcome, not an exception; only the SUBSCRIBE itself
failing throws. Classify() is parameterised by operation name (messages unchanged
for connect).

JSONPath is a documented subset ($, $.a.b, $['a'], $.a[0]); filters/slices/
recursive descent report a miss rather than pulling in a JSONPath package for an
expression form that selects a set where a tag needs one scalar.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 16:12:35 -04:00
Joseph Doherty f2a9ee5d93 docs(mqtt): flag the Last-Will landmine at the browse-options seam
A will is published by the BROKER, so it is invisible to PublishCountForTest.
MqttDriverOptions carries none today, but Sparkplug NDEATH is a will message -
once P2 adds it, an ungraceful browse disconnect would fire NDEATH under the
plant's own edge-node identity. ToBrowseOptions is where it must be cleared.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:46:23 -04:00
Joseph Doherty 7980b34692 feat(mqtt): bespoke passive #-observation browser (plain)
MQTT has no discovery protocol, so the AdminUI address picker learns the topic
space the only way there is: subscribe to the wildcard and accumulate what
arrives. MqttBrowseSession serves that observation as a segment tree (split on
'/'); MqttDriverBrowser opens it with a distinct "{clientId}-browse-{guid8}"
identity under a 5-30 s clamped budget.

Browsing publishes NOTHING - the load-bearing safety property, since the picker
runs against a live plant broker. Every outbound message must route through the
single PublishAsync seam that counts into PublishCountForTest; P2's operator-
triggered Sparkplug rebirth is the one intended exception. Sparkplug mode fails
fast rather than serving a raw spBv1.0 topic tree that looks like a metric tree.

Deviation from the plan's ref list: the project also references .Driver so the
browse CONNECT reuses MqttConnection.BuildClientOptions (TLS posture, CA-pin
chain validator, credentials). A second copy would be a security divergence.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:45:32 -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 a6370a26f8 fix(mqtt): make connect idempotent, bound the reconnect callback, stop State lying
Closes the connect-vs-connect defect: MQTTnet throws (and raises no DisconnectedAsync)
on a connect-while-connected, so a caller's ConnectAsync and the reconnect supervisor
corrupted each other in both directions. Both paths now check first; when the supervisor
finds the session already restored it stands down WITHOUT firing Reconnected.

Reconnected becomes Func<CancellationToken, Task>, fed from the lifetime token and capped
at ConnectTimeoutSeconds, so a hung re-subscribe can no longer park the supervisor.
Connected is published only after the re-subscribe succeeds; a supervisor that dies now
reports Faulted instead of Reconnecting forever.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:32:41 -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 768fd87774 feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:58:37 -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 cacc30a60d feat(mqtt): last-value cache backing IReadable (per-ref no-data)
LastValueCache bridges MQTT's subscribe-first push model to the OPC UA
server's polled IReadable.ReadAsync: the subscription path calls Update()
per RawPath, the read path calls Read(). Read never throws; an unseen
RawPath returns BadWaitingForInitialData (0x80320000) rather than an
exception or null, so a batch covering many references degrades per-ref
instead of failing wholesale.

Deviates from the plan's GoodNoData snippet: GoodNoData is reserved
repo-wide for "the historian window held no samples" (NullHistorianDataSource,
OtOpcUaNodeManager HistoryRead paths); BadWaitingForInitialData is the
established convention for "no live value observed yet" (CalculationDriver,
VirtualTagEngine, FOCAS, AddressSpaceApplier). Keyed by RawPath per the v3
driver-reference identity, not a topic/JSON-path-derived key.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:40:15 -04:00
Joseph Doherty 5629f3698d fix(mqtt): pin the CA accept path, honour presented intermediates, classify the dispose race
Review follow-ups on MqttConnection (Task 3):

- Every certificate test asserted rejection, so the accept branch was
  unreachable-by-regression. Adds a leaf genuinely issued by the pinned CA and
  asserts acceptance.
- ValidateAgainstPinnedCa never seeded ChainPolicy.ExtraStore from the incoming
  chain, so a leaf behind an intermediate delivered during the handshake failed
  despite a legitimate path to the pinned root. Seeds from both the incoming
  chain's elements and its ExtraStore; CustomRootTrust still means only the
  pinned roots may terminate the chain.
- A DisposeAsync racing an in-flight connect escaped as an unclassified
  exception; it now folds into ObjectDisposedException.
- Promotes the single-caller concurrency invariant into the type remarks, with
  the accurate blast radius (a leaked live connection, not a benign throw).
  Serialising the lifecycle remains Task 4's job.
- X509Chain.Build can throw; an exception escaping a TLS validation callback is
  an opaque handshake crash, so it is caught and refused.
- Adds a connect-retry test (Task 4's reconnect loop reuses the instance) and a
  disposed-then-connect test.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:38:16 -04:00
Joseph Doherty ded4c41798 fix(mqtt): key tag definitions by RawPath, not the TagConfig blob
Task 2 review follow-up. The plan specified MqttEquipmentTagParser.TryParse(reference)
with def.Name = the TagConfig blob, and told us to mirror a type named
ModbusEquipmentTagParser. Both are plan defects:

- EquipmentTagRefResolver documents that a v3 driver reference "is now always a
  RawPath" and that the blob-parse fallback is retired. Keying Name by the blob
  would make OnDataChange publish under a reference that never matches the
  RawPath-keyed fan-out in DriverHostActor - silently dead in production with
  every unit test still green.
- ModbusEquipmentTagParser does not exist. The six sibling drivers all use
  <Driver>TagDefinitionFactory.FromTagConfig(tagConfig, rawPath, out def).

Changes:
- Rename MqttEquipmentTagParser -> MqttTagDefinitionFactory; TryParse(reference,
  out def) -> FromTagConfig(tagConfig, rawPath, out def) setting Name: rawPath,
  matching ModbusTagDefinitionFactory's structure, param docs and guard order.
- Pin the identity contract with a dedicated test so a regression to blob-keying
  goes red.
- Read qos with the same strictness as the enums: a present-but-invalid qos
  ("high" / 1.5 / 5 / null) now rejects the tag and is warned by Inspect,
  instead of being silently absorbed into the driver-level default and handing
  the operator a weaker delivery guarantee than the one they authored.
- No ToTagConfig inverse: the siblings carry one solely for their Driver.<X>.Cli
  project, the MQTT plan defines none, and the AdminUI editor template
  references no driver factory. Recorded as an explicit YAGNI call in the type
  doc rather than added speculatively.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:31:36 -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 726d6d198e feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:24:04 -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 e3fea6e409 feat(mqtt): plain tag parser + strict-enum descriptor
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:07:11 -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 a5a8710af5 fix(mqtt): pin security-critical defaults with tests + redact password in ToString
Code review of f22db5d8 (approved-with-findings): the existing round-trip
tests only exercised explicit JSON payloads, so nothing failed if UseTls /
AllowUntrustedServerCertificate or the §5.1 numeric defaults were flipped.
Adds a defaults-pinning test plus a partial-JSON test that omits the TLS
knobs entirely (the production case — an operator config that just doesn't
mention TLS must not silently land insecure).

Also overrides the record's PrintMembers so ToString() no longer prints
Password in plaintext (verified RED before the fix: the new test failed
with the raw password in the rendered string). OpcUaClientDriverOptions has
the same unredacted-ToString() shape but is out of scope for this task per
the coordinator's note; flagging as a possible follow-up.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:01:01 -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 f22db5d801 feat(mqtt): Contracts options DTO + enums (name-serialized)
Task 1 of the MQTT/Sparkplug B driver plan: MqttMode, MqttPayloadFormat,
MqttProtocolVersion enums + MqttDriverOptions (broker conn + mode +
nullable Sparkplug/Plain sub-options) per design doc §5.1. Removes the
Task 0 temporary MQTTnet PackageReference from .Contracts (transport-free;
references only Core.Abstractions). MQTTnet stays pinned in
Directory.Packages.props for the .Driver project (Task 3).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:53:06 -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 d677ac7ad3 feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:45:21 -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
373 changed files with 68693 additions and 5095 deletions
+122 -9
View File
@@ -55,6 +55,28 @@ about OtOpcUa changes here — remote/push status, the driver set, the Galaxy da
shared-lib consumption, or per-project commands — update the **OtOpcUa entry in
`../scadaproj/CLAUDE.md`** in the same change so the index never drifts from this repo.
**The index edit belongs on `scadaproj`'s `main`, and must be pushed.** `scadaproj` is a
separate repo with its own checkout, so committing there inherits whatever branch it
happens to be on — which is usually *not* `main` and is often an unrelated feature branch
with no upstream. That silently drifts the index for as long as that branch stays
unmerged, which is the exact failure the rule above exists to prevent. Do this explicitly:
```bash
cd ~/Desktop/scadaproj
git rev-parse --abbrev-ref HEAD # note it, to restore afterwards
git stash list # never commit onto a dirty unrelated branch
git checkout main && git pull
# …edit the OtOpcUa entry in CLAUDE.md…
git commit -am "docs(index): <what changed about OtOpcUa>"
git push origin main
git checkout - # restore the original branch
```
Observed 2026-07-27: the `Sql` poll driver's index entry had sat unpushed on an unrelated
feature branch since 2026-07-24 because it was committed onto the current checkout, so the
index disagreed with reality for three days despite this rule. If you find index commits
stranded on a feature branch, cherry-pick them onto `main` rather than merging that branch.
## Architecture Overview
### Data Flow
@@ -131,7 +153,71 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double,
### Change Detection
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. The server's `DriverHost` consumes the signal and rebuilds the address space.
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
the same driver object. A raise is recorded and rides the driver-health snapshot
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
**"re-browse" chip**.
⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
records.
Two gotchas if you touch this:
- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in
that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the
operator. Pinned by `DriverInstanceActorRediscoverySignalTests`.
- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on
every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests
use a stub with stable health for exactly this reason.
**#507 is closed as superseded, and the injection path is deleted.** Its retained code read
`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3
(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log
line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop
(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18
`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft
(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw`
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
the actor.
**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
only way to notice a PLC re-download.
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
legitimately means "nothing authored, so everything discovered is new".
⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
reports only what it can see and **says so** in the operator message rather than implying a clean bill of
health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
built purely from the Agent's probe model.
Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
as Gitea **#521**.
## mxaccessgw
@@ -168,13 +254,29 @@ central SQL Server.
> recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test
> harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail.
> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. The driver fixture compose files under `tests/.../Docker/docker-compose.yml` now carry a `project: lmxopcua` label on every service. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28).
> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28).
> ⚠️ **The `project: lmxopcua` label is aspirational, not universal.** This note used to claim every fixture compose file carries it. As of 2026-07-24 only the **MQTT** fixture actually does — `grep -rn "labels:" tests --include=*.yml` matches `Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` and nothing else. So `docker ps --filter label=project=lmxopcua` returns the MQTT fixture alone, not the fleet. Add the label when you touch an older fixture; until then enumerate by container name.
Docker workloads run on a shared Linux host at **`10.100.0.35`** — not on this VM. Stacks live at `/opt/otopcua-<driver>/` on the host and carry the `project=lmxopcua` label so they're discoverable via `docker ps --filter label=project=lmxopcua`.
**`docker -H ssh://...` does NOT work from this VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side.
**`docker -H ssh://...` does NOT work from the Windows VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side.
**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from this VM:**
> **On macOS there is no `lmxopcua-fix` — drive the host over SSH directly.** The helper is a Windows-VM
> script; `~/bin` is empty on the Mac and the commands below will not resolve. Use passwordless SSH
> (and `rsync` in place of `sync`), which is what `infra/README.md` §1 documents as the Mac path:
>
> ```bash
> ssh dohertj2@10.100.0.35 'docker ps --format "{{.Names}}\t{{.Status}}"'
> ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose up -d'
> rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/ \
> dohertj2@10.100.0.35:/opt/otopcua-mqtt/ # the `sync` step, by hand
> ```
>
> A local container runtime *does* exist on the Mac (OrbStack), so the `docker-dev/` rig itself runs
> locally there — it is only the shared **fixtures** that live on `10.100.0.35`.
**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from the Windows VM:**
```powershell
lmxopcua-fix ls # list all lmxopcua-tagged containers on the host
@@ -195,6 +297,16 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/
- AB CIP: `10.100.0.35:44818` (`AB_SERVER_ENDPOINT`)
- S7: `10.100.0.35:1102` (`S7_SIM_ENDPOINT`)
- OPC UA reference (opc-plc): `opc.tcp://10.100.0.35:50000` (`OPCUA_SIM_ENDPOINT`)
- MQTT (Mosquitto, **auth on both listeners — no anonymous fallback**): `10.100.0.35:8883` TLS+auth
(`MQTT_FIXTURE_ENDPOINT`) · `10.100.0.35:1883` plaintext-but-authenticated (`MQTT_FIXTURE_PLAIN_ENDPOINT`).
Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the
fixture CA (`scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt`). A co-running publisher loops
JSON under `otopcua/fixture/…`.
- MQTT **Sparkplug B**: the same broker, plus the `sparkplug` compose profile's `otopcua-sparkplug-sim`
(project-owned C# edge-node simulator) — group **`OtOpcUaSim`**, edge nodes **`EdgeA`**/**`EdgeB`**,
`EdgeA` device **`Filler1`**, ~2 s cadence. It subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}` and
re-births on request. **Births are never retained**, so `docker restart otopcua-sparkplug-sim` is how
you force a fresh one while a browse window is open.
Override any endpoint via the env var to point at a real PLC. The local OtOpcUa server runs on this VM at `opc.tcp://localhost:4840`**that's not on the docker host**.
@@ -221,7 +333,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
**Two corrections landed 2026-07-21 — both were total-outage or wrong-node defects, and both are worth knowing before touching this area.**
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is still open** — the pathology only appears 1-vs-1 and docker-dev is a single six-node mesh, so the crash-the-oldest drill is deferred to the per-cluster mesh work.
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is CLOSED** — Phase 7 drill D4 (2026-07-24) ran the 1-vs-1 crash-the-oldest survival drill on the three-mesh docker-dev rig and every survivor stayed Up, never self-downing (`c50ebcf7`; `docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
- **The Primary is the oldest Up `driver` member, not the role leader.** `RedundancyStateActor` previously used `ClusterState.RoleLeader("driver")` (lowest *address*), while `ClusterSingletonManager` places singletons on the *oldest*. They agree only on a freshly-formed cluster and diverge after any restart, so the Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) could enable on a node not hosting the singletons. `NodeRedundancyState.IsRoleLeaderForDriver` was renamed **`IsDriverPrimary`**.
**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".
@@ -332,7 +444,7 @@ Flip only the site nodes with `OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker com
## Live telemetry transport (`Telemetry` / `TelemetryDial`)
Per-cluster mesh **Phase 5** (code-complete on `feat/mesh-phase5`, live gate pending) added a second
Per-cluster mesh **Phase 5** (merged `35552a96`; live gate PASSED `f134935f`) added a second
transport for the four live-observability channels the AdminUI's `/alerts`, `/script-log`, and
`/hosts` panels feed on, selected by `Telemetry:Mode` (node/serve side) and `TelemetryDial:Mode`
(central/dial side): `Dps` — the four existing DPS SignalR bridges, **the default**, today's
@@ -442,7 +554,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
- **Scripted-alarm Part 9 condition state moved to LocalDb.** `LocalDbAlarmConditionStateStore`
(replicated `alarm_condition_state` table, pair-local like the Phase-2 alarm S&F buffer) replaces
`EfAlarmConditionStateStore` on every driver-role node, fused central included. The DB-backed
`ScriptedAlarmState` table is now unused (dropping it is a deferred follow-up, tracked as Task 9).
`ScriptedAlarmState` table **was dropped** (Task 9, `3a590a0c`, migration
`20260723183050_DropScriptedAlarmStateTable`); `EfAlarmConditionStateStore` is deleted.
- **Central persists deploy acks.** `DriverHostActor` no longer writes `NodeDeploymentState` to SQL on
a driver-only node — `ConfigPublishCoordinator.PersistNodeAck` (already fed by every ApplyAck) is
the system of record.
@@ -451,8 +564,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
`OtOpcUaGroupRoleMapper` falls back to the `Security:Ldap:GroupToRole` appsettings baseline only —
the same rows a driver node never received via config either, so this is not a regression.
Code-complete on `feat/mesh-phase4`; the table-drop (Task 9) and the live gate (Task 10) are still
open. See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes run ConfigDb-free) and
Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
## LDAP Authentication
+18
View File
@@ -75,8 +75,26 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<!--
MQTT client for the Mqtt / Sparkplug B driver. MQTTnet v5 is the first line shipping a
net10.0 TFM (lib/net10.0, and an EMPTY net10.0 dependency group — zero transitive
packages, so it cannot participate in a diamond under this repo's deliberately-OFF
CentralPackageTransitivePinningEnabled). Sparkplug B payloads are hand-rolled rather than
taken from SparkplugNet, whose newest release (1.3.10) still pins MQTTnet 4.3.6.1152 and
ships net6.0/net8.0 only.
-->
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
<!-- Driver.MTConnect carries NO backend NuGet. The TrakHound MTConnect.NET-Common/-HTTP pins
Task 0 added were removed in Task 7 (2026-07-24): neither package can parse an MTConnect
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
<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
+3 -3
View File
@@ -1,6 +1,6 @@
# OtOpcUa
OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (gateway).
OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, OPC UA Client (gateway), and MQTT (broker subscribe; Sparkplug B in progress).
A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia desktop app — connects to any OPC UA server.
@@ -15,7 +15,7 @@ A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia de
| address space + capability fan-out|
+-------------------------------------+
| | | | | | | |
Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient
Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient MQTT
|
v
mxaccessgw (sibling repo, gRPC)
@@ -91,7 +91,7 @@ See [docs/Client.CLI.md](docs/Client.CLI.md) and [docs/Client.UI.md](docs/Client
|---|---|
| Driver specs (per-driver capability surface, config, addressing) | [docs/v2/driver-specs.md](docs/v2/driver-specs.md) |
| Galaxy driver | [docs/drivers/Galaxy.md](docs/drivers/Galaxy.md) |
| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient | [docs/drivers/](docs/drivers/) |
| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient / MQTT | [docs/drivers/](docs/drivers/) |
| Galaxy parity rig (mxaccessgw setup) | [docs/v2/Galaxy.ParityRig.md](docs/v2/Galaxy.ParityRig.md) |
| Galaxy performance + tracing | [docs/v2/Galaxy.Performance.md](docs/v2/Galaxy.Performance.md) |
+16
View File
@@ -29,6 +29,11 @@
<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.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.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" />
@@ -42,6 +47,9 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj" />
</Folder>
<Folder Name="/src/Drivers/Driver CLIs/">
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
@@ -90,6 +98,11 @@
<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.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.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" />
@@ -104,6 +117,9 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj" />
</Folder>
<Folder Name="/tests/Drivers/Driver CLIs/">
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
@@ -9,78 +9,110 @@
},
{
"id": 1,
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) must FAIL at f6eaa267",
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) \u2014 must FAIL at f6eaa267",
"status": "completed",
"blockedBy": [0]
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
"status": "completed",
"blockedBy": [0, 1]
"blockedBy": [
0,
1
]
},
{
"id": 3,
"subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
"status": "completed",
"blockedBy": [1, 2]
"blockedBy": [
1,
2
]
},
{
"id": 4,
"subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
"status": "completed",
"blockedBy": [3]
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
"status": "completed",
"blockedBy": [4]
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) must FAIL",
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) \u2014 must FAIL",
"status": "completed",
"blockedBy": [0]
"blockedBy": [
0
]
},
{
"id": 7,
"subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
"status": "completed",
"blockedBy": [6]
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
"status": "completed",
"blockedBy": [7]
"blockedBy": [
7
]
},
{
"id": 9,
"subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)",
"status": "completed",
"blockedBy": [8]
"blockedBy": [
8
]
},
{
"id": 10,
"subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync <remarks> (STAB-8 S7 part, note-only)",
"status": "completed",
"blockedBy": [3]
"blockedBy": [
3
]
},
{
"id": 11,
"subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)",
"status": "deferred-live",
"status": "completed",
"note": "env-gated; runs in serial live pass (integration suite; serialized heavy pass). Test authored + committed; verified it SKIPs cleanly offline.",
"blockedBy": [5]
"blockedBy": [
5
],
"closureNote": "LIVE GATE CLOSED 2026-07-15 \u2014 S7 blackhole run via `docker pause` on the 10.100.0.35 s7_1500 fixture. Found + fixed a real read-leg wall-clock gap (PR #453). STATUS.md 'docker-dev live-/run pass'."
},
{
"id": 12,
"subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)",
"status": "completed",
"note": "S7 unit 246/246 green; S7 CLI 48/49 (1 pre-existing unrelated comment-scan failure, see deviations); solution BUILD clean (0 errors). Whole-solution dotnet test NOT run per controller memory-constraint (integration suites deferred to serial heavy pass).",
"blockedBy": [5, 7, 8, 9, 10, 11]
"blockedBy": [
5,
7,
8,
9,
10,
11
]
}
],
"lastUpdated": "2026-07-13"
"lastUpdated": "2026-07-27"
}
@@ -1,25 +1,170 @@
{
"planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md",
"tasks": [
{ "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "completed", "blockedBy": [] },
{ "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "completed", "blockedBy": [] },
{ "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] },
{ "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] },
{ "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] },
{ "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] },
{ "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] },
{ "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] },
{ "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] },
{ "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "completed", "blockedBy": [7, 5] },
{ "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "completed", "blockedBy": [2, 6] },
{ "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "completed", "blockedBy": [5, 10] },
{ "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "completed", "blockedBy": [10, 11] },
{ "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "completed", "blockedBy": [11] },
{ "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] },
{ "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] },
{ "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] },
{ "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] },
{ "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "deferred-live", "note": "integration/full sweep; serialized heavy pass", "blockedBy": [4, 8, 9, 12, 15, 17] }
{
"id": 0,
"subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267",
"status": "completed",
"blockedBy": []
},
{
"id": 1,
"subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)",
"status": "completed",
"blockedBy": []
},
{
"id": 2,
"subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests",
"status": "completed",
"blockedBy": [
0,
1
]
},
{
"id": 3,
"subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)",
"status": "completed",
"blockedBy": [
0,
1
]
},
{
"id": 4,
"subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests",
"status": "completed",
"blockedBy": [
2,
3
]
},
{
"id": 5,
"subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 6,
"subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 7,
"subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false \u2014 RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 9,
"subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor",
"status": "completed",
"blockedBy": [
7,
5
]
},
{
"id": 10,
"subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)",
"status": "completed",
"blockedBy": [
2,
6
]
},
{
"id": 11,
"subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests",
"status": "completed",
"blockedBy": [
5,
10
]
},
{
"id": 12,
"subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard",
"status": "completed",
"blockedBy": [
10,
11
]
},
{
"id": 13,
"subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text",
"status": "completed",
"blockedBy": [
11
]
},
{
"id": 14,
"subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green",
"status": "completed",
"blockedBy": [
13
]
},
{
"id": 15,
"subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)",
"status": "completed",
"note": "docker-dev :9200 live-/run; serial live pass",
"blockedBy": [
14
],
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 range-validation banner verified on the docker-dev Modbus driver's Resilience overrides (`timeoutSeconds:0`). STATUS.md 'docker-dev live-/run pass'."
},
{
"id": 16,
"subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 17,
"subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 18,
"subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md",
"status": "completed",
"note": "integration/full sweep; serialized heavy pass",
"blockedBy": [
4,
8,
9,
12,
15,
17
],
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 S-8 verified: Write and AlarmAcknowledge RETRIES cells disabled ('never'). STATUS.md 'docker-dev live-/run pass'."
}
],
"lastUpdated": "2026-07-12"
"lastUpdated": "2026-07-27"
}
@@ -3,7 +3,7 @@
"tasks": [
{
"id": "R2-03-T1",
"subject": "S13 stale-Good repro test at host level (RED must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
"subject": "S13 stale-Good repro test at host level (RED \u2014 must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
"status": "completed",
"blockedBy": []
},
@@ -62,7 +62,7 @@
},
{
"id": "R2-03-T9",
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes \u2014 identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
"status": "completed",
"blockedBy": [
"R2-03-T3"
@@ -86,7 +86,8 @@
"R2-03-T8",
"R2-03-T10"
],
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)"
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)",
"openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile."
},
{
"id": "R2-03-T12",
@@ -95,8 +96,9 @@
"blockedBy": [
"R2-03-T11"
],
"note": "docker-dev live-/run; serial live pass"
"note": "docker-dev live-/run; serial live pass",
"openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
}
],
"lastUpdated": "2026-07-13"
"lastUpdated": "2026-07-27"
}
@@ -1,22 +1,146 @@
{
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
"lastUpdated": "2026-07-12",
"lastUpdated": "2026-07-27",
"tasks": [
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] },
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
{ "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." },
{ "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." },
{ "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] }
{
"id": "T1",
"subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)",
"status": "completed",
"blockedBy": []
},
{
"id": "T2",
"subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)",
"status": "completed",
"blockedBy": [
"T1"
]
},
{
"id": "T3",
"subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)",
"status": "completed",
"blockedBy": [
"T1"
]
},
{
"id": "T4",
"subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)",
"status": "completed",
"blockedBy": [
"T2"
]
},
{
"id": "T5",
"subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using",
"status": "completed",
"blockedBy": [
"T1"
]
},
{
"id": "T6",
"subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T7",
"subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T8",
"subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T9",
"subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T10",
"subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T11",
"subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate",
"status": "completed",
"blockedBy": [
"T3",
"T6",
"T7",
"T8",
"T9",
"T10"
]
},
{
"id": "T12",
"subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T13",
"subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T14",
"subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)",
"status": "completed",
"blockedBy": [
"T12",
"T13"
],
"note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint."
},
{
"id": "T15",
"subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)",
"status": "completed",
"blockedBy": [
"T14"
],
"note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate \u2014 controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin).",
"closureNote": "LIVE GATE CLOSED 2026-07-13 (partial by design) \u2014 every config/read page renders under the all-roles multi-role-test session, so named-policy gating does not over-gate a legitimate admin. The DENY direction is unobservable on docker-dev (DisableLogin=true), as documented."
},
{
"id": "T16",
"subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)",
"status": "completed",
"blockedBy": [
"T15"
]
}
]
}
@@ -1,16 +1,16 @@
{
"planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md",
"lastUpdated": "2026-07-12",
"lastUpdated": "2026-07-27",
"tasks": [
{
"id": "T1",
"subject": "RED: misconfig repro test HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
"subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
"status": "completed",
"blockedBy": []
},
{
"id": "T2",
"subject": "GREEN: adapter guard Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
"subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
"status": "completed",
"blockedBy": [
"T1"
@@ -50,7 +50,7 @@
},
{
"id": "T7",
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType 0.2.0 proto3-optional presence witness; pin, no RED phase)",
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)",
"status": "completed",
"blockedBy": []
},
@@ -89,12 +89,13 @@
{
"id": "T12",
"subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T9",
"T11"
],
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 gateway EnsureTags threw ArgumentOutOfRangeException ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (FloatDouble=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (BooleanInt1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md."
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker \u2192 real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 \u2192 gateway EnsureTags threw ArgumentOutOfRangeException \u2192 ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float\u2192Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException \u2014 Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean\u2192Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md.",
"closureNote": "LIVE GATE CLOSED 2026-07-13 (6/6) \u2014 vs. the local 0.2.0 gateway + real historian over VPN. STATUS.md 'docker-dev live-/run pass'."
}
]
}
@@ -1,6 +1,6 @@
{
"planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md",
"lastUpdated": "2026-07-12",
"lastUpdated": "2026-07-27",
"tasks": [
{
"id": "T1",
@@ -45,20 +45,22 @@
{
"id": "T5",
"subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T4b"
],
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client)."
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client).",
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 central-1 boot logged PureAdd (added=91, rebuild=False)."
},
{
"id": "T6",
"subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) \u2014 Phase 1 shippable boundary",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T5"
],
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good)."
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good).",
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 deleting a UNS virtual tag logged PureRemove (removed=1, rebuild=False) and the node vanished from the address space."
},
{
"id": "T7",
@@ -104,11 +106,12 @@
{
"id": "T12",
"subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T11"
],
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk."
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 surgical add AND remove both execute in prod with no full rebuild; the remove path's 3 new sink members are wired, not inert."
},
{
"id": "T13",
@@ -121,11 +124,12 @@
{
"id": "T14",
"subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T13"
],
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk."
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T5/T6/T12. STATUS.md 'docker-dev live-/run pass'."
}
]
}
@@ -1,17 +1,94 @@
{
"planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
"lastUpdated": "2026-07-12",
"lastUpdated": "2026-07-27",
"tasks": [
{ "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] },
{ "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] },
{ "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] },
{ "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] },
{ "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] },
{ "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "completed", "blockedBy": ["T7"] },
{ "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] },
{ "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "deferred-live", "blockedBy": ["T6", "T8", "T9"] },
{ "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] }
{
"id": "T1",
"subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)",
"status": "completed",
"blockedBy": []
},
{
"id": "T2",
"subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)",
"status": "completed",
"blockedBy": [
"T1"
]
},
{
"id": "T3",
"subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)",
"status": "completed",
"blockedBy": [
"T1"
]
},
{
"id": "T4",
"subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName",
"status": "completed",
"blockedBy": []
},
{
"id": "T5",
"subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)",
"status": "completed",
"blockedBy": [
"T1",
"T4"
]
},
{
"id": "T6",
"subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)",
"status": "completed",
"blockedBy": [
"T5"
]
},
{
"id": "T7",
"subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)",
"status": "completed",
"blockedBy": [
"T4"
]
},
{
"id": "T8",
"subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges",
"status": "completed",
"blockedBy": [
"T7"
]
},
{
"id": "T9",
"subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit \u2014 verified by T10)",
"status": "completed",
"blockedBy": [
"T7"
]
},
{
"id": "T10",
"subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim",
"status": "deferred-live",
"blockedBy": [
"T6",
"T8",
"T9"
],
"openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
},
{
"id": "T11",
"subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10",
"status": "completed",
"blockedBy": [
"T10"
]
}
]
}
@@ -1,30 +1,213 @@
{
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
"lastUpdated": "2026-07-12",
"lastUpdated": "2026-07-27",
"tasks": [
{ "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] },
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] },
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] },
{ "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] },
{ "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] },
{ "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] },
{ "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] },
{ "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] },
{ "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] },
{ "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
{
"id": "T1",
"subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)",
"status": "completed",
"blockedBy": []
},
{
"id": "T2",
"subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)",
"status": "completed",
"blockedBy": []
},
{
"id": "T3",
"subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)",
"status": "completed",
"blockedBy": []
},
{
"id": "T4",
"subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)",
"status": "completed",
"blockedBy": []
},
{
"id": "T5",
"subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)",
"status": "completed",
"blockedBy": [
"T1",
"T3"
]
},
{
"id": "T6",
"subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost \u2192 DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers",
"status": "completed",
"blockedBy": [
"T4",
"T5"
]
},
{
"id": "T7",
"subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent",
"status": "completed",
"blockedBy": [
"T5"
]
},
{
"id": "T8",
"subject": "DraftValidator swap: Configuration\u2192Commons ProjectReference; ExtractTagConfigFullName \u2192 TagConfigIntent.ExplicitFullName (null-on-absent preserved)",
"status": "completed",
"blockedBy": [
"T2",
"T3"
]
},
{
"id": "T9",
"subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites",
"status": "completed",
"blockedBy": [
"T2",
"T3"
]
},
{
"id": "T10",
"subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits",
"status": "completed",
"blockedBy": [
"T3",
"T5"
]
},
{
"id": "T11",
"subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)",
"status": "completed",
"blockedBy": []
},
{
"id": "T12",
"subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T13",
"subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T14",
"subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T15",
"subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T16",
"subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T17",
"subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)",
"status": "completed",
"blockedBy": [
"T11"
]
},
{
"id": "T18",
"subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)",
"status": "completed",
"blockedBy": [
"T17"
]
},
{
"id": "T19",
"subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)",
"status": "completed",
"blockedBy": []
},
{
"id": "T20",
"subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)",
"status": "completed",
"blockedBy": [
"T12",
"T13",
"T14",
"T15",
"T16",
"T17"
]
},
{
"id": "T21",
"subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test",
"status": "completed",
"blockedBy": [
"T20"
]
},
{
"id": "T22",
"subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)",
"status": "completed",
"blockedBy": [],
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 the typed Modbus tag editor shows the Writable toggle and saving persists `writable: yes`."
},
{
"id": "T23",
"subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up",
"status": "completed",
"blockedBy": [
"T17",
"T21"
]
},
{
"id": "T24",
"subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate",
"status": "completed",
"blockedBy": [
"T5",
"T6",
"T7",
"T8",
"T9",
"T10",
"T12",
"T13",
"T14",
"T15",
"T16",
"T17",
"T18",
"T19",
"T21",
"T22",
"T23"
],
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T22. STATUS.md 'docker-dev live-/run pass'."
}
]
}
+667
View File
@@ -0,0 +1,667 @@
# Deferment register — new drivers, open issues, deferred work
> **Scope.** A source-verified inventory of what is unfinished in this repo: the status of the
> recently-added drivers, every open tracker issue, in-code deferrals, open live gates, and the
> documentation that misstates any of it.
>
> **Method.** Five parallel agents swept the tree independently; every claim below was checked
> against **source** (`src/`, `tests/`, `git log`), not against documentation. Where a doc and the
> code disagreed, the code won and the doc is listed in §7. Findings that could not be verified
> without hardware or a live rig are marked **CANNOT VERIFY** rather than asserted.
>
> **Baseline.** `master` @ `90bdaa44`, 2026-07-27. All local branches are merged into master; the
> only unmerged remotes are three abandoned branches (`origin/auto/driver-gaps` 184 commits behind
> since 2026-04-26, `origin/auto/driver-gaps-stash`, `origin/refactor/galaxy-mxgateway-client-rename`).
---
## 0. The short version
The driver runtime is in good shape — **no new driver contains a stub, a `NotImplementedException`,
or an unregistered factory.** What is unfinished clusters into four groups, in descending order of
how much they should worry you:
| # | Theme | Why it matters |
|---|---|---|
| **1** | **Three subsystems are fully authored, persisted, and shipped — and never executed.** Node ACLs, `IRediscoverable`/`IHostConnectivityProbe`, and `DriverTypeRegistry`. | An operator can author a rule, save it, deploy it green, and have it do **nothing**. §3 |
| **2** | **AdminUI authoring-dispatch gaps.** Calculation is offered in the driver picker but has no config form; three drivers have no device form; CSV + browse-commit dispatch maps miss the new drivers. | The "registered but unauthorable" class that has now bitten twice. §1.2 |
| **3** | **17 open issues, all confirmed still open**, several broader than filed. | §2 |
| **4** | **Bookkeeping drift.** 8 `.tasks.json` files show ~140 "pending" tasks for work that shipped; 5 CLAUDE.md status claims are stale. | Someone follows the tracker and rebuilds a shipped driver. §6, §7 |
Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silent non-enforcement.
---
## 1. New drivers — source-verified status
Cross-cutting facts, verified once: all 12 driver types register in one place
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`); all 12 register a
probe (`:119-130`) via `AddOtOpcUaDriverProbes()`, called from **both** the driver path (`:93`) and
the admin path — **no probe is admin-node-missing**. `DriverTypeNames`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`) is guarded bidirectionally by
reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs`.
### 1.1 Status table
| Driver | Verdict | Merged | Capabilities | Notes |
|---|---|---|---|---|
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
`catch` filter or a deliberate domain error.
### 1.2 Gaps found — AdminUI authoring & dispatch layer
Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
| # | Gap | Evidence | Impact |
|---|---|---|---|
| G-1 | **`DriverConfigModal.razor` has no `Calculation` case** — falls to the `default` "No typed config form" warning; no `CalculationDriverForm.razor` exists | `DriverConfigModal.razor:34-72`, default arm `:69-71` | `CalculationDriverOptions.RunTimeout` (`CalculationDriverOptions.cs:19`) is **unauthorable via the UI**. Create/rename still work, so degraded — not a hard block. **This is the same class as the Sql picker defect, one modal further downstream.** |
| G-2 | **`DeviceModal.razor` is missing Sql, MTConnect, Calculation** | `DeviceModal.razor:42-74`, default arm `:71-73` | Mitigated: Sql/MTConnect hold connection at the **driver** level, and Test Connect still works via `BuildMergedProbeConfig` (`:180-184`). |
| G-3 | **`DriverConfigModal.razor:77` tells Sql/MTConnect operators the wrong thing** — the single-connection branch is hardcoded to `Galaxy or Mqtt`, so Sql/MTConnect users are told the endpoint lives on the *device* when they just authored it on the driver | `DriverConfigModal.razor:77`, `:86-88` | Actively misleading, compounds G-2. |
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`**`RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames`**picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. |
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. |
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. |
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. |
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
- `DriverTypeNames.cs:22-25` — Calculation described as "not-yet-registered"; it registers at `DriverFactoryBootstrap.cs:149`.
- `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct.
- `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C".
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh
instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed**
the comment now says the premise was false when written and is true *now* because a config change
respawns. See §9.
---
## 2. Open tracker issues — all 17 verified against source
**No issue was found stale.** 11 CONFIRMED, 4 PARTIALLY (core claim holds, a sub-claim is wrong or
incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
| # | Title (abbrev.) | Verdict | Key evidence | Kind |
|---|---|---|---|---|
| **518** | `IRediscoverable` + `IHostConnectivityProbe` have no consumer; CLAUDE.md wrong | **CONFIRMED — worse than filed** | Only `+=` in `src/` are Galaxy self-wiring (`GalaxyDriver.cs:586`, `:221`). `GetHostStatuses()` has **zero call sites** outside the interface + implementations; `DriverHostStatuses` DbSet is referenced only by its declaration and a test. | Defect + doc error |
| **507** | Discovered-node injection inert in v3 | **CONFIRMED** | `DriverHostActor.cs:986-1002` hard-`return;` + `#pragma warning disable CS0162` (restored `:1077`) | Deferred (deliberate guard) |
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed**`StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
| **516** | Driver config edits silently discarded | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** | `.Browser``.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/``RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **507**→ see above | | | | |
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238``:540``ActorHistorizedTagSubscriptionSink.cs:27-38``ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
| **481** | Comms-loss → scripted-alarm quality (Layer 4) | **CONFIRMED** | `DriverHostActor.cs:1356-1379` iterates `_alarmNodeIdByDriverRef` only. **Issue is wrong that the per-driver ref set is missing**`_nodeIdByDriverRef` exists at `:193`; only the fan-out into the mux is absent, so the work is *smaller* than filed | Deferred feature |
| **468** | Wave-0 browser tree render | **CANNOT VERIFY** (fixture) | Code path present (`AbCipDriver.cs:1092-1095`, `LibplctagTagEnumerator.cs:29-37`); `ab_server` returns `ErrorUnsupported` for CIP Symbol Object enumeration | Deferred verification |
### 2.1 Cross-cutting observations
1. **#518#507 are the same failure, stacked.** The event has no subscriber *and* its downstream
handler hard-returns. **Fixing either alone changes nothing observable** — schedule them together.
2. **#516 is a superset of part of #489.** For the five drivers that discard reinit config, the
renamed-tag-never-rebinds symptom follows mechanically (their `_tagsByRawPath` still keys on the
old RawPath). Re-scope #489 after #516.
3. **#519 and #481 share the same `statusCode >> 30` collapse idiom** at three independent sites
(`DriverInstanceActor.cs:1033`, `ScriptedAlarmEngine.cs:745`, `:777`). Widening the status path
must cover all three.
---
## 3. Dead seams — authored, shipped, never executed
**The most consequential category in this register**, and the one nothing in the code flags. Three
subsystems are fully built and reachable by operators, but no production code path executes them.
### 3.1 Node-level ACLs are authorable, deployed — and unenforced
- `IPermissionEvaluator`, `TriePermissionEvaluator`, `PermissionTrieCache`, `PermissionTrieBuilder`
have **zero references outside `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and their own
tests** (independently re-verified for this report: every other grep hit is a `bin/`/`obj/` XML doc artifact).
- `OtOpcUaNodeManager` contains **no reference to `Permission` or `Evaluator` at all.**
- Meanwhile `ClusterAcls.razor` + `AclEdit.razor` let operators author `NodeAcl` rows, and
**`ConfigComposer.cs:51` snapshots them into every deployment artifact.**
**Impact:** an operator authors a deny rule, deploys it green, and it has no effect. Actual
enforcement is coarse LDAP roles plus the realm-qualified `WriteOperate` gate in the node manager —
and that is a **write** gate; reads carry no per-node ACL check.
**`docs/ReadWriteOperations.md:13,21` states the opposite** (see §7.1) — it is the single
highest-risk doc error found.
### 3.2 `IRediscoverable` + `IHostConnectivityProbe` raise into the void
Gitea #518/#507. Nine drivers raise; nothing consumes. `DriverHostStatus` table exists
(`OtOpcUaConfigDbContext.cs:48`, migration `20260715230637_V3Initial.cs:108`) with a doc-comment
claiming a writer — **nothing ever writes a row.**
**Impact:** an Agent/PLC/Galaxy restart leaves a stale address space behind a Healthy driver, and the
per-host connectivity table is permanently empty.
### 3.3 `DriverTypeRegistry` is vestigial
Independently verified for this report: `src/Core/…Core.Abstractions/DriverTypeRegistry.cs:20` is
referenced **only** by `tests/Core/…/DriverTypeRegistryTests.cs`. Nothing registers metadata at
startup; nothing validates `DriverInstance.DriverType` against it. Its `AllowedNamespaceKinds` field
was retired with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`).
**Knock-on:** the driver **stability tier** does not come from here either. The live source is
`DriverFactoryRegistry.cs:46` (`DriverTier tier = DriverTier.A`) and **no factory in `src/Drivers/`
passes a tier** — so all 12 drivers run Tier A, and the Tier-C-only protections are dormant for
every driver: `MemoryRecycle.cs:54` (`when _tier == DriverTier.C`) and
`ScheduledRecycleScheduler.cs:43` (refuses unless Tier C). `docs/v2/driver-stability.md` still
assigns Galaxy and FOCAS to Tier C.
### 3.4 Related dead/dormant code
| Item | Evidence |
|---|---|
| `IDriverConfigEditor` — interface, zero implementors, zero production consumers | `Core.Abstractions/IDriverConfigEditor.cs:9`; superseded by `TagConfigEditorMap` / `DriverConfigModal` |
| `GenericDriverNodeManager` — explicitly **not** a production dispatch path | `Core/OpcUa/GenericDriverNodeManager.cs:71` ("verified 07/#10: zero production references") |
| `IDriverSupervisor` — Tier-C recycle machinery has **zero implementations**, yet the parser still validates `RecycleIntervalSeconds` | archreview 01/U-2 |
| Resilience **bulkhead** — documented, defaulted, parsed, AdminUI-authorable; `Build` never adds a concurrency limiter | archreview 01/U-6 |
| `WriteIdempotentAttribute` — zero readers; write dispatch hardcodes `isIdempotent: true` | archreview 01/S-8 = 03/S12 |
| ~60 lines of unreachable code retained behind #507's guard | `DriverHostActor.cs:1002`, `#pragma warning disable CS0162` |
| Three empty `Driver.Historian.Wonderware*` directories (0 `.cs`, no `.csproj`, absent from `.slnx`) | verified for this report — only stale `bin/`+`obj/` remain |
---
## 4. In-code deferrals by subsystem
The tree is unusually clean on classic markers: **9 `TODO`s across 1,812 files; zero
`FIXME`/`HACK`/`XXX`/`WORKAROUND`; zero `#if false`; zero `[Obsolete]` on project code; zero
commented-out DI registrations.** Real deferred work lives in prose doc-comments and skipped tests.
### 4.1 Highest-consequence
| Item | Evidence |
|---|---|
| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
| **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354`**cross-repo**, blocked on mxaccessgw |
| **Subscription liveness undetectable**`ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" |
| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426`**verify**; may be reachable now via `DiscoveryDriverBrowser` |
### 4.2 Per-driver
- **S7** — array **writes** unsupported entirely (`S7Driver.cs:1139`); several array *read* types unsupported (`:386,404,413,419,428,442,465,807,959`) — all fail fast at config-validation; legacy C-area counter reinterpretation live-hardware-gated (`:759`); Counter BCD on S7-300/400 undecoded.
- **AbCip** — ALMD-only alarm projection, ALMA deferred (`AbCipAlarmProjection.cs:16,324`); no CIP Template Object reader, UDT layouts declaration-driven (`AbCipDriverOptions.cs:172`); whole-UDT writer deferred (`LibplctagTagRuntime.cs:190`); `AckCmd` pulse must be wired client-side (`:30`).
- **Sql** — `SqlTagModel.Query` deferred to P3, **enforced end-to-end** across parser, validator, planner and editor (`SqlProvider.cs:22,37`; `SqlEquipmentTagParser.cs:24,103`; `SqlGroupPlanner.cs:44,249`; `SqlTagConfigModel.cs:114`) — exemplary deferral discipline. Postgres/ODBC dialects deferred behind `ISqlDialect`.
- **MTConnect** — `TIME_SERIES` vectors deferred to P1.5; `DATA_SET`/`TABLE` coded `BadNotSupported` with a null value rather than approximated (`MTConnectObservationIndex.cs:53,272`) — deliberately chosen over a Good-coded lie. `RawTagEntry.DeviceName` ignored for routing (two `/raw` Devices under one driver share an Agent connection) — "a design change, not a bug fix". No DataType-vs-blob validation at authoring.
- **MQTT** — `MaxPayloadBytes` is a constructor knob, not operator-facing (`MqttSubscriptionManager.cs:170`).
- **TwinCAT** — Flat-mode struct expansion carries a **LIVE RISK** note, unverified against a real TC3 target (`AdsTwinCATClient.cs:270`); VirtualTree-mode browse unverified; TC2 coverage, multi-hop AMS route and lab rig all hardware-blocked (`docs/v3/twincat-backlog.md`).
- **FOCAS** — forces read-only regardless of authored `writable:true`; servo-load semantics inferred from the wire, unconfirmed against a real CNC (`FocasWireClient.cs:944`); `"Backend": "unimplemented"` is a **deliberate fail-fast stub**, not unfinished work.
- **Historian.Gateway** — String/DateTime/Reference writes deferred, gated on the analog SQL write path; `UInt16 → Uint4` widening because the `UInt2` write path is deferred upstream (`HistorianTypeMapper.cs:27-51`).
### 4.3 Fleet-wide OPC UA gaps
- **Array writes deferred across all drivers**; **multi-dimensional arrays (`ValueRank>1`) unsupported**; **array historization** is an opaque blob with no per-element history (`docs/Uns.md:288-292`, `AddressSpaceApplier.cs:873`, `OtOpcUaNodeManager.cs:1764`).
- **`HistoryUpdate`** — permission bit `1<<12` shipped but excluded from every composite bundle; no service surface (`NodePermissions.cs:34`).
- **`Utils.Log*` is `[Obsolete]`** in SDK 1.5.378, suppressed at 8 sites in `OtOpcUaNodeManager` pending an `ITelemetryContext` rewire.
### 4.4 Skipped tests — 36, from 4 reason constants
| Count | Reason | Assessment |
|---|---|---|
| 18 | `DiscoveryInjectionDormantV3` | **Real** — blocked on #507 |
| 13 | `EquipmentTagsDarkBatch4` | **STALE** — the reason says these unblock "at Batch 4"; Batch 4 shipped as v3.0 and the path was *retired* (`AddressSpaceApplier.cs:415` calls it "the vestigial equipment-tag path"). These will never unskip as written — delete or rewrite the reason. |
| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is |
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
`…ReadWriteTests`, `…HistoryTests`, all `:10`).
All env-gated integration suites **skip cleanly** when their vars are absent. The MTConnect gate is
notably a **real `/probe` reachability + payload-shape check** (`MTConnectAgentFixture.cs:42`), not
mere env-var presence, so it cannot pass vacuously.
---
## 5. Open live gates
| Gate | Status | Evidence |
|---|---|---|
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
| **archreview R2-10** — force breaker-OPEN live | **OPEN** — breaker-OPEN state never forced live | `archreview/plans/R2-10-*.tasks.json` |
| **Driver-reconfigure-while-faulted** Task 3 | **OPEN** — task subject literally "DEFERRED" | `2026-07-14-driver-reconfigure-while-faulted-plan.md.tasks.json` |
| Galaxy `ScanStateProbeParityTests` | **OPEN** — licence-gated (one `$WinPlatform` on this box) | `docs/v2/Galaxy.ParityRig.md:180` |
| **v2 GA exit criteria** — FOCAS live-CNC wire smoke (#54), OPC UA CTT/compliance pass, Ignition 8.3 redundancy cutover | **OPEN** — manual/hardware/external-tool | `docs/v2/v2-release-readiness.md:105-120` |
| mesh Phase 4 | ✅ **PASSED** `1281aebf` | CLAUDE.md said open — **corrected**, §7.2 |
| mesh Phase 5 | ✅ **PASSED** `f134935f` | CLAUDE.md said pending — **corrected**, §7.2 |
| auto-down 1-vs-1 crash-the-oldest | ✅ **PASSED** — Phase 7 drill D4, `c50ebcf7` | CLAUDE.md said open — **corrected**, §7.2 |
**Note on the 14 archreview `deferred-live` tasks across 8 plans:** `archreview/plans/STATUS.md:242-305`
records a 2026-07-13 live pass closing several (R2-02, R2-07, R2-11, R2-04, R2-05-partial) and
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** Only
R2-03 and R2-10 have no closure record anywhere.
---
## 6. Plan bookkeeping — stale vs. live
**59 of 78 `docs/plans/*.tasks.json` are 100 % complete.** Of the remaining 19, most are stale
bookkeeping, not backlog.
### 6.1 STALE — work shipped, file never updated (~140 phantom "pending" tasks)
| File | Recorded | Reality |
|---|---|---|
| `2026-07-24-mtconnect-driver.md.tasks.json` | 23 pending | Merged at master tip `90bdaa44`, 5-leg live gate PASSED |
| `2026-07-24-mqtt-sparkplug-driver.md.tasks.json` | 27 pending | All 27 done, both phases live-gated |
| `2026-07-24-sql-poll-driver.md.tasks.json` | 22 pending | Merged `4ad54037` + follow-ups `28c28667` |
| `2026-07-24-modbus-rtu-driver.md.tasks.json` | 11 pending | Merged `0f38f486` (#495), live gate PASSED |
| `2026-06-26-otopcua-historian-gateway-integration.md.tasks.json` | 21 pending | Shipped as PR #423, live-validated |
| `2026-06-15/16-stillpending-phase-{0-1,2,3,4}.md.tasks.json` | 12+9+10+8 pending | Shipped — audit §D cites `1e95856b`, `ada01e1a`, `fc8121cb`, `3e609a2b`, `70e6d3d2`, `c236263e`, `bd8fee61`, `5e27b5f7`, `fcb38014` |
| `2026-07-22-per-cluster-mesh-program.md.tasks.json` | Phase 2 in_progress, 37 pending | Plan body `:331-342` says **program COMPLETE** |
| `2026-06-12-historian-tcp-transport.md.tasks.json` | 12 pending | **OBSOLETE** — targets the retired Wonderware sidecar |
### 6.2 GENUINELY LIVE
| File | Open | Note |
|---|---|---|
| **`2026-05-29-adminui-followups.md.tasks.json`** | **19 pending / 0 completed** | **The largest unexecuted plan in the tree.** Generic `CollectionEditor<TRow>`; per-driver device/tag editors ×7; typed resilience form; `RoleMapper.Merge` + DB-backed LDAP→role mapping + `RoleGrants.razor` + FleetAdmin authz. **Several items look superseded by v3's `TagConfigEditorMap` — verify overlap before executing.** |
| `2026-06-19-followups-batch.md.tasks.json` | 4 pending + 1 partial | B4 F10b surgical DataType/IsArray writes and B5 alarm-severity `SetSeverity` are **OPEN-by-choice, not built** |
| `2026-06-26-otopcua-fixedtree-followups.md.tasks.json` | 1 pending | Task 11 build + regression |
| `2026-05-26-akka-hosting-alignment-plan.md.tasks.json` | 5 partial | F8/F9/F10/F13/F14 — oldest open items; **likely superseded by v3, verify** |
| `2026-05-28-adminui-driver-pages-plan.md.tasks.json` | 1 partial | 10.4 manual smoke checklist |
`docs/plans/2026-07-23-mesh-phase4-…tasks.json` Task 9 was the only `deferred`-flagged mesh task —
**it has since landed** (`3a590a0c`), so that file is now stale too.
### 6.3 Review-directory state
- **`code-reviews/`** — 51 modules, **zero Open findings**. One textual "Left Open pending a decision"
survives in a finding body (`Configuration/findings.md:254`, LDAP collation) whose status field
reads Deferred — the only index inconsistency.
- **`code-reviews/` coverage gap** — **10 shipped source projects have never been reviewed**:
`Driver.Calculation`, `Driver.Historian.Gateway`, `Driver.Mqtt` (+`.Browser`, `.Contracts`),
`Driver.MTConnect` (+`.Contracts`), `Driver.Sql` (+`.Browser`, `.Contracts`). Meanwhile three
modules review the **retired** Wonderware backend.
- **`archreview/`** — `00-OVERALL.md:60-79` carries an explicit "Carried open … no remediation branch
targeted them" block. The largest coherent batch is the **failure-visibility trio**: 01/S-1
(`AddressSpaceApplier` swallows every sink failure, deploy reports Applied even when broken), 03/S4
(primary gate default-allow on unknown role), 06/S-1 (Galaxy optimistic write success).
- **`stillpending.md` does not exist** — the backlog is `docs/plans/2026-06-18-stillpending-backlog-audit.md`.
---
## 7. Documentation corrections
### 7.1 Applied in this pass
| File | Was | Now |
|---|---|---|
| `CLAUDE.md` §Change Detection (`:156`) | "The server's `DriverHost` consumes the signal and rebuilds the address space" | ⚠️ nothing consumes it; names `DriverHostActor.cs:986`, #518/#507, and the four affected drivers |
| `CLAUDE.md` §Redundancy (`:272`) | auto-down "live gate is still open … docker-dev is a single six-node mesh" | gate **CLOSED** by Phase 7 drill D4 (`c50ebcf7`); the six-node-mesh claim also contradicted CLAUDE.md's own Phase 6 paragraph |
| `CLAUDE.md` §Telemetry (`:383`) | Phase 5 "code-complete on `feat/mesh-phase5`, live gate pending" | merged `35552a96`; live gate PASSED `f134935f` |
| `CLAUDE.md` §Phase 4 (`:493`, `:502-503`) | `ScriptedAlarmState` "dropping it is a deferred follow-up, tracked as Task 9"; "Task 9 and the live gate (Task 10) are still open" | table **dropped** (`3a590a0c`, migration `20260723183050_DropScriptedAlarmStateTable`); live gate PASSED `1281aebf` |
| `docs/plans/2026-07-24-driver-expansion-tracking.md` | Modbus RTU "pending merge"; SQL poll "📝 Plan ready (22 tasks)"; **an "Executing a plan" table instructing you to rebuild both** | both marked ✅ Done with merge SHAs; the two shipped rows struck from the command table; Wave 1/2 headings and §Next actions corrected |
| `docs/drivers/README.md` | `DriverTypeRegistry` "registered at startup"; "namespace kinds (Equipment + SystemPlatform)"; no Sql/Calculation rows; "Modbus TCP" only | registry marked vestigial + tier truth; namespace line corrected to Raw+UNS; Sql + Calculation rows added; Modbus row covers RTU-over-TCP |
| `docs/drivers/TwinCAT.md` (`:95`, `:99-103`, `:151`) | "so the address space is rebuilt" / "so Core rebuilds the address space" | raises-but-unconsumed caveat, mirroring the wording `Mqtt.md`/`MTConnect.md` already use correctly |
| `docs/drivers/Galaxy.md` (`:73`) | `IRediscoverable` row with no caveat | same caveat added |
| `docs/v2/Runtime.md` (`:106`) | "named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink`" | gRPC to HistorianGateway + `LocalDbStoreAndForwardSink` |
| **`docs/ReadWriteOperations.md`** (`:13`, `:21` + banner) | "ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch"; "**A denied read never hits the driver**" | Both struck through and corrected; banner states plainly that **no per-node ACL gate exists on any operation** and that `GenericDriverNodeManager` is test scaffolding. **This was the highest-risk doc error found.** |
| **`docs/IncrementalSync.md`** (banner) | whole "Rebuild flow" + generation-publish path presented as current | Banner: `GenericDriverNodeManager` is non-production, rediscovery has no consumer, `sp_PublishGeneration`/`sp_ComputeGenerationDiff` `RAISERROR`, `DraftRevisionToken` gone; names the one live path (deploy artifact) |
| **`docs/AddressSpace.md`** (banner) | "single custom namespace"; `Phase7Applier`/`Phase7Composer`/`GalaxyTagPlan`; `SystemPlatform` | Banner: two namespaces w/ `V3NodeIds`, the `AddressSpace*` rename (`40e8a23e`), `AddressSpaceComposition` as the result type, `SystemPlatform` retired |
| `docs/v2/phase-7-status.md`, `v2-release-readiness.md`, `redundancy-interop-playbook.md`, `AdminUI-rebuild-plan.md` (banners) | present-tense claims about types that no longer exist | Dated "⚠️ Historical" banners naming the specific dead types. `v2-release-readiness.md`'s banner also flags that its `:41` "ACL enforcement Closed" row is false, while noting its unchecked GA exit criteria **are** still live. (`docs/StatusDashboard.md` already self-declares Superseded — left alone.) |
| `docs/OpcUaServer.md` (`:14`, `:51`), `docs/README.md` (`:65`), `docs/drivers/OpcUaClient-Test-Fixture.md` (`:127`) | `WriteAlarmState`; `LdapUserAuthenticator` ×2; `RedundancyCoordinator` | `WriteAlarmCondition`; `LdapOpcUaUserAuthenticator`; `RedundancyStateActor` + `IRedundancyRoleView` |
| `docs/Historian.md` | no mention of #491 or the provisioning gap | ⚠️ block added: continuous value capture is **code-complete but unproven in production**, with the wiring chain; plus the `EnsureTags`-skips-existing-tags gap |
### 7.2 Recorded, NOT applied — needs a decision or a rewrite, not a line edit
The three highest-priority items in this list were **bannered rather than rewritten** in this pass
(cheap, stops the misleading, preserves the record) — the underlying rewrite is still owed. They now
appear in §7.1 as well.
| Priority | File | Problem |
|---|---|---|
| **1 (security-relevant)** | `docs/ReadWriteOperations.md:13,21` | **Bannered + struck through.** Claimed ACL enforcement via `WriteAuthzPolicy` + `AuthorizationGate` + `NodeScopeResolver` and that "a denied read never hits the driver". All four names have zero hits in `src/`; §3.1 shows there is no read-path ACL gate at all. **Still owed:** a proper §-rewrite against the real node-manager write gate. Same false claim at `docs/v2/v2-release-readiness.md:41` (bannered). |
| **2** | `docs/IncrementalSync.md` | **Bannered.** Entire "Rebuild flow" (`:37-47`) built on non-production `GenericDriverNodeManager`; `:26-34` cites `sp_PublishGeneration`/`sp_ComputeGenerationDiff`, both retired stubs that `RAISERROR` (`20260715230637_V3Initial.StoredProcedures.cs:194`); `:31` cites `DraftRevisionToken` (0 hits); `:49-51` describes a `ScopeHint` no consumer reads. **Still owed:** rewrite around the deploy-artifact path, or archive to `docs/v1/`. |
| **2** | `docs/AddressSpace.md` | **Bannered.** v2-era throughout: `:7,42` claim a single namespace (v3 has two); `:7,48,70` cite `Phase7Applier` + `GalaxyTagPlan` + a nonexistent file path; `:62-64` repeats the rediscovery claim. **Still owed:** rewrite §"Root folder" + §"NodeId scheme" against `V3NodeIds` / `AddressSpaceRealm`. |
| 3 | 6 docs, ~20 citations | **`Phase7*``AddressSpace*`** rename (`40e8a23e`). Mostly mechanical, **except three that are not renames** and need the real member identified first: `Phase7CompositionResult`**`AddressSpaceComposition`** (`AddressSpaceComposer.cs:13`, verified for this report); `Phase7Composer.ExtractTagFullName` → nearest live seam is `ScriptTagCatalog.ExtractFullNameFromTagConfig`; **`Phase7EngineComposer.RouteToHistorianAsync` is simply gone** — the real seam is `HistorianAdapterActor``IAlarmHistorianSink`. |
| 3 | `docs/v2/driver-specs.md` | Covers 8 of 12 drivers (missing MQTT, MTConnect, Sql, Calculation, and the Modbus `Transport` selector) while `docs/drivers/README.md` calls it authoritative "for **every** driver". Either add four sections or downgrade the claim. |
| 3 | `docs/v2/driver-stability.md:47-54` | Puts Galaxy and FOCAS in Tier C; `docs/drivers/README.md` says Tier A for both. **Moot at runtime** — see §3.3 — but the two docs contradict each other. |
| 3 | `docs/drivers/Sql.md`, `docs/drivers/Calculation.md` | **Do not exist.** Sql is documented only in design/plan/research files; Calculation only at `docs/Raw.md:66-69`. |
| — | `docs/operations/2026-07-16-secrets-clustered-master-key.md:38` | `NoOpSecretReplicator` exists **only** in a test. **Investigate before editing** — the sentence makes a security-relevant claim ("no built-in cross-node replication today"), so it needs the real behaviour identified, not a rename. Left untouched deliberately. |
| — | `docs/v2/driver-specs.md` §2 | `docs/drivers/Modbus.md:10-11` points readers here for the Modbus config shape, and this file omits the `Transport` selector. Folded into the driver-specs item above. |
---
## 8. Recommended sequencing
1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or
remove the authoring UI and say plainly that ACLs are not enforced. Either way fix
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
**Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
2. ~~**#518 + #507 together** — neither fix is observable alone.~~**Done.** The framing was right
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
so it was deleted rather than fixed. See §9.
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
**Done.** See §9. #489 is still open and should now be re-scoped.
4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
G-1, G-2, G-3, G-5 and G-6. See §9.
5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
(§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9.
6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete
code decision is Gitea **#522**. See §9.
---
## 9. Execution log
Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as
the work, so this register never disagrees with the tree.
| §8 item | Status | Commit |
|---|---|---|
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
| 2. #518 + #507 | ✅ **Done**`IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` |
| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` |
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `03658c2e` |
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `03658c2e` |
### 2026-07-27 — §8.1 ACL non-enforcement made explicit
Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and
track the wire-up as **Gitea #520**.
Applied:
- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a
**What is enforced today** table (the three real checks) before the design material, which is
labelled *Designed but not wired*.
- `docs/ReadWriteOperations.md`**rewritten**, not bannered.
- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner.
- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked
as not holding.
- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but
never evaluated.
**Four things this pass found that §7 and the original audit missed** — all worse than what was
already recorded:
1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an
anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is
true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address
space. It is refused only writes and alarm acks — and only because it carries no roles.
2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares
exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared
against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to
`ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**".
3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero**
occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at
all: the node manager is push-model and the SDK serves a client Read from the cached pushed value.
`WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise
zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all.
4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1
already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`,
`MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or
Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree.
Also recorded in #520 and not previously known: every existing evaluator unit test runs in
`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path
is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
silently.
### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
Decision: **signal, not mutation.**
`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
browse-commit and a runtime graft would materialise nodes nobody approved.
**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
`Runtime.Tests` went 31 → 13.
**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
the diff is permanently empty for them. It would have been another plausible-looking inert seam —
precisely the class this register exists to remove. That also removed the last consumer of the
connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
and dropping the result.
Two traps worth keeping:
- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
**Verified by reverting the one line — 3 of the 5 new tests go red.**
- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
the new tests use a stub with stable health.
`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
inference would be wrong. Split to Gitea **#521** with both options costed.
### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth
**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the
4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the
retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote`
naming the evidence, so the next reader can check rather than trust.
**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since
2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15,
R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.
**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline
("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is
"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not
forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture
feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's
`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker.
**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely
unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than
being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented
`resolved-no-flip` decision rather than work.
**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational —
every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also
corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it
describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went
in-process when its managed wire client landed). No runtime change — turning recycle on for two live
drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
more than options from config, so adopting new options alone would run a **new tag set against an old
connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
| Driver | What one parse now yields | Adopted by |
|---|---|---|
| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
Details worth keeping:
- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
come from the same revision.
- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
cannot silently displace what a test passed in.
- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
without a process restart.
- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
half-adopting.
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
driver that simply never opted in).
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
detection is complementary there.
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
driver where both directions work** — its stream is purely probe-model-derived.
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
engineering event, not a runtime one.
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2
survived review while the sibling picker guard stayed green (the picker test works only because
`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from
`DriverConfigFormMap` / `DeviceFormMap` via `<DynamicComponent>`, and being `public` those maps need
none of the picker test's `BindingFlags.NonPublic` fragility.
| Gap | Resolution |
|---|---|
| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. |
| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. |
| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. |
| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). |
| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. |
| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. |
**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that
`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name —
and a column name alone cannot address a Sql tag, which needs its table. The browser now travels
schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone
would have produced a half-built blob.
**Two extra findings while writing the guards:**
- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key.
Both are correct — neither is browsable — so the test asserts the fall-through set **equals** a
documented non-browsable list, in both directions. A one-way check would let a newly-browsable driver
be quietly added to the exclusion list instead of getting a branch.
- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had
already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new
`DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a
coverage test in the other direction.
**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit
and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where
it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**,
proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on
the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt
image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end.
### 2026-07-27 — §8.3 #516 driver config edits silently discarded
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
"per-driver" half in two, which the register did not anticipate:
- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each
factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the
constructor options.
- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options**
from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both
injected at construction. Adopting new options alone would run a **new tag set against an old
connection**. Half a re-parse is worse than none. Their doc-comments now say so.
**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed
`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority.
`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate
decision documented at `DriverSpawnPlan.cs:49-50`** ("a pure DriverConfig change stays an in-place
delta — no reconnect"). That reasoning was right about resilience and wrong about the driver; the
accepted price is a reconnect on every config edit.
Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the
child had dequeued the message**, so the host immediately believed the new config was live and the
next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no
`Receive<ApplyResult>` registered, so a failed reinit (including Galaxy's deliberate
`NotSupportedException`) dead-lettered.
**A new visibility gap this change exposed, not created:** a factory throw is a *config* error
(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently
substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can.
Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the
deployment — making it do so would let one malformed driver block a fleet deploy, so that is a
deliberate follow-up rather than a drive-by change.
**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a
guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by
construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by
deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were
rewritten: `DriverSpawnPlannerTests` (two cases), and
`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription`
a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync`
to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its
desired set. Same event, different route; the control still calibrates the settle window.
Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3,
verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker
fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite).
---
*Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
Every `path:line` reference was read from source. Items marked CANNOT VERIFY require hardware, a
VPN'd gateway, or a live rig and are listed as unproven rather than assumed.*
+34
View File
@@ -0,0 +1,34 @@
# Isolated MQTT/Sparkplug live-gate overlay (Task 26).
#
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
# sibling driver worktrees that share `otopcua-host:dev` are untouched.
#
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mqtt .
# docker compose -p otopcua-mqtt -f docker-dev/docker-compose.yml \
# -f docker-dev/docker-compose.mqtt.yml \
# up -d sql migrator cluster-seed central-1 central-2
#
# AdminUI -> http://localhost:9210 (login disabled; auto-admin)
# OPC UA -> opc.tcp://localhost:4850 (central-1) / :4851 (central-2)
# SQL -> localhost,14350
#
# `!override` on every `ports` list: compose MERGES list-valued keys by
# appending, so a plain re-declaration keeps the base file's "14330:1433" /
# "4840:4840" and collides with the running `otopcua-dev` rig.
services:
sql:
ports: !override
- "14350:1433"
central-1:
image: otopcua-host:mqtt
ports: !override
- "4850:4840"
- "9210:9000"
central-2:
image: otopcua-host:mqtt
ports: !override
- "4851:4840"
+45
View File
@@ -0,0 +1,45 @@
# Isolated MTConnect live-gate overlay (Task 21).
#
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
# three sibling driver worktrees that share `otopcua-host:dev` are untouched.
# Mirrors how the mqtt-sparkplug worktree isolated itself (`otopcua-host:mqtt`,
# project `otopcua-mqtt`, ports 9210/4850).
#
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mtconnect .
# docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml \
# -f docker-dev/docker-compose.mtconnect.yml \
# up -d sql migrator cluster-seed central-1 central-2
#
# AdminUI -> http://localhost:9220 (login disabled; auto-admin)
# OPC UA -> opc.tcp://localhost:4860 (central-1) / :4861 (central-2)
# SQL -> localhost,14340
#
# Both MAIN nodes run because `cluster-seed` declares MAIN as a 2-node cluster:
# ConfigPublishCoordinator sources its expected-ack set from enabled ClusterNode
# rows, so deploying with one node down fails at the apply deadline.
#
# Traefik is deliberately not started — central-1 publishes its AdminUI port
# directly, which removes the :9200 round-robin that otherwise makes it
# ambiguous which node served a request.
# NOTE: `!override` is required on every `ports` list. Compose MERGES list-valued
# keys by appending, so a plain re-declaration would keep the base file's
# "14330:1433" / "4840:4840" alongside the offsets and collide with the running
# `otopcua-dev` rig ("Bind for 0.0.0.0:14330 failed: port is already allocated").
services:
sql:
ports: !override
- "14340:1433"
central-1:
image: otopcua-host:mtconnect
ports: !override
- "4860:4840"
- "9220:9000"
central-2:
image: otopcua-host:mtconnect
ports: !override
- "4861:4840"
+12
View File
@@ -272,6 +272,12 @@ services:
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"
@@ -391,6 +397,12 @@ services:
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"
+22
View File
@@ -106,6 +106,28 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- Backfill: GrpcPort on rows that predate the column (#493)
--
-- Every insert above is INSERT-if-not-exists, which is the right shape for a re-runnable seed
-- but means a *new nullable* column never reaches rows created by an earlier version of this
-- file. On a persisted volume that is exactly what happened when Phase 5 added GrpcPort: the
-- six ClusterNode rows already existed, so they kept NULL, TelemetryNodeSource found no dial
-- targets, and TelemetryDial:Mode=Grpc silently connected to nothing (throttled Warning per
-- node — the code degrades gracefully, so nothing failed loudly).
--
-- Backfills NULL only. It must not overwrite a port an operator set deliberately on a
-- long-lived dev volume; a NULL is the unambiguous "this row predates the column" marker.
--
-- AkkaPort deliberately has no equivalent here: it is NOT NULL with a default of 4053, so
-- pre-existing rows already carry a usable value and there is nothing to backfill.
------------------------------------------------------------------------------
UPDATE dbo.ClusterNode
SET GrpcPort = 4056 -- matches Telemetry__GrpcListenPort on all six nodes in docker-compose.yml
WHERE GrpcPort IS NULL
AND CreatedBy = 'docker-dev-seed';
------------------------------------------------------------------------------
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
--
+22
View File
@@ -1,5 +1,27 @@
# Address Space
> ⚠️ **Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3
> address space.** Corrections, in order of how badly they mislead:
>
> - **There is no "single custom namespace".** v3 exposes **two**:
> `https://zb.com/otopcua/raw` (`s=<RawPath>`) and `https://zb.com/otopcua/uns`
> (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`), registered together at
> `OtOpcUaNodeManager.cs:45`. Identity authority is `V3NodeIds` + `AddressSpaceRealm`. The retired
> `https://zb.com/otopcua/ns` survives only in a comment marking it retired (`V3NodeIds.cs:17`).
> - **`Phase7Applier` / `Phase7Composer` / `GalaxyTagPlan` do not exist**, nor do the file paths cited
> for them. Renamed by `40e8a23e` to `AddressSpaceApplier` / `AddressSpaceComposer`
> (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`); the composition result type is
> `AddressSpaceComposition` (`AddressSpaceComposer.cs:13`).
> - **The `SystemPlatform` namespace kind is retired** — Galaxy is a standard Equipment-kind driver.
> - **`GenericDriverNodeManager` is test scaffolding**, not a production dispatch path
> (`GenericDriverNodeManager.cs:71`).
> - **The §Rediscovery section is wrong**: nothing consumes `OnRediscoveryNeeded`
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)), and the driver list is stale
> (MQTT and MTConnect now raise it too).
>
> For the current design see the "v3 OPC UA Address Space (Batch 4)" section of `CLAUDE.md`,
> `docs/Raw.md`, and `docs/Uns.md`.
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
## Root folder
+15
View File
@@ -114,6 +114,21 @@ source it from there.
> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors
> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7).
> ⚠️ **Continuous value capture is code-complete but UNPROVEN IN PRODUCTION
> ([#491](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/491)).** The recorder, the FasterLog
> outbox, the gateway value-writer and the deploy-time historized-ref feed
> (`AddressSpaceApplier.FeedHistorizedRefs` → `ActorHistorizedTagSubscriptionSink` →
> `ContinuousHistorizationRecorder.UpdateHistorizedRefs`) are all wired, and the recorder converges to
> the deployed ref set from the first deploy onward. What has **not** run is the end-to-end live gate
> (deploy → dependency-mux value delta → outbox append → `WriteLiveValues`) plus a restart-convergence
> test. **Reads and alarm-writes are live-validated; treat value capture as unproven rather than
> absent.** See the "KNOWN LIMITATION 2" section of `CLAUDE.md`.
>
> **Related, separately open:** `EnsureTags` provisioning fires only for **newly-added** historized
> tags (`AddressSpaceApplier` iterates the added set), so flipping an **existing** tag to
> `"isHistorized": true` never provisions it — see
> `docs/plans/2026-06-27-historian-gateway-integration-issues.md` Issue 5.
---
## Tag auto-provisioning (EnsureTags)
+21
View File
@@ -1,5 +1,26 @@
# Incremental Sync
> ⚠️ **Accuracy warning (audited 2026-07-27) — most of this page describes machinery that is retired
> or was never wired.** Read it as v2-era design history, not as current behaviour.
>
> - **"Rebuild flow" (below) is not a production path.** It is built on `GenericDriverNodeManager`,
> which is Core test scaffolding with **zero production references**
> (`GenericDriverNodeManager.cs:71`). Its `_variablesByFullRef` / `_sourceByFullRef` fields do not
> exist in `src/`.
> - **Driver-backend rediscovery has no consumer.** Nothing in `src/Server/` or `src/Core/`
> subscribes to `IRediscoverable.OnRediscoveryNeeded`; `DriverHostActor.HandleDiscoveredNodes`
> (`DriverHostActor.cs:986-1002`) short-circuits unconditionally. The `ScopeHint` is read by
> nothing. Drivers that raise it today: Galaxy, TwinCAT, MQTT/Sparkplug, MTConnect
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
> [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)).
> - **The generation-publish path is retired.** `sp_PublishGeneration` and
> `sp_ComputeGenerationDiff` are stubs that `RAISERROR`
> (`20260715230637_V3Initial.StoredProcedures.cs:194`); `DraftRevisionToken` does not exist.
>
> **The one live rebuild path in v3** is the deploy artifact: `ConfigComposer`
> `Deployment.ArtifactBlob``DriverHostActor` apply → `AddressSpaceComposer` /
> `AddressSpaceApplier` diff-apply.
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
## Driver-backend rediscovery — IRediscoverable
+2 -2
View File
@@ -11,7 +11,7 @@ In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM
- `CreateMasterNodeManager` constructs one `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) — a `CustomNodeManager2` subclass that owns the writable address space under the namespace `https://zb.com/otopcua/ns` and a single `OtOpcUa` root folder organized under the standard `Objects` folder. It is wrapped in a `MasterNodeManager` with no additional core managers.
- `OtOpcUaSdkServer.NodeManager` exposes the live node manager after `StartAsync`, so the hosting layer can wrap it in a `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) and hand it to `OpcUaPublishActor`.
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor``OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmState` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor``OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmCondition` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
The driver-agnostic walk that turns a driver's discovery into folder/variable calls lives in `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`): it walks `ITagDiscovery.DiscoverAsync` into an `IAddressSpaceBuilder`, captures alarm-condition sinks for variables flagged via `IVariableHandle.MarkAsAlarmCondition`, subscribes to `IAlarmSource.OnAlarmEvent`, and routes each alarm transition to the sink registered for its `SourceNodeId`.
@@ -48,7 +48,7 @@ The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpc
`OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows:
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapOpcUaUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`.
+1 -1
View File
@@ -62,7 +62,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
| [Uns.md](Uns.md) | The global `/uns` master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs |
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
| [Redundancy.md](Redundancy.md) | `RedundancyStateActor` (cluster-scoped singleton), `ServiceLevelCalculator`, `IRedundancyRoleView`, per-cluster meshes, Prometheus metrics |
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
| [ServiceHosting.md](ServiceHosting.md) | Single fused `OtOpcUa.Host` binary install/uninstall with `OTOPCUA_ROLES` gating; the historian backend is the external HistorianGateway |
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
+96 -43
View File
@@ -1,67 +1,120 @@
# Read/Write Operations
The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks,
> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
> like the way it was documented. See `deferment.md` §3.1 and §7.
## Driver vs virtual dispatch
## The shape of it: push for reads, pull for writes
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
single most important thing on this page:
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue`
handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer
(`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and
every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the
cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail
with a device error — it returns whatever quality was last pushed.
- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver.
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
`OpcUaServer` project does not reference it at all.
## OnReadValue
## Read path
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
session — including an Anonymous one — can read every node in the address space. The only gating is
the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
a per-user decision.
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time.
3. (Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
[docs/security.md](security.md) § Data-Plane Authorization).
The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
## Write path — `OnEquipmentTagWrite`
## OnWriteValue
The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and
re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no
handler, so the SDK rejects the write before any of this runs.
`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence.
1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to
be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒
`BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node,
pre-boot) ⇒ `BadNotWritable`.
- This is a **server-wide** check. It takes no node and no realm: a session that may write one tag
may write every writable tag on that node.
- `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only
`WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no
code path reads them.
2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately.
3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the
backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the
same driver ref, so a write via either one reaches the same device point.
4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted**
on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write
cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so
it can never surface a write failure.)
### Authorization (two layers)
`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
## Alarm method calls
### Dispatch
`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143:
- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration.
- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field.
### Per-write status
`IWritable.WriteAsync` returns `IReadOnlyList<WriteResult>` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
## Array element writes
Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the
`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
with `BadUserAccessDenied`.
## HistoryRead
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`.
Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
This is not a per-driver `IHistoryProvider` dispatch.
**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
node-manager `Lock` while invoking them, so these paths may await.
See [docs/Historian.md](Historian.md).
## Failure isolation
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
Exceptions in a driver capability call are logged and converted to a per-node bad status rather than
unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the
same process.
## What is *not* enforced anywhere
Collected here because the previous revision of this page claimed several of these:
| Surface | Check |
|---|---|
| Read, Browse, TranslateBrowsePaths | none |
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
| HistoryRead (all four variants) | none |
| Non-Value attribute writes | none (the handler is on `OnWriteValue`) |
| Value write | `WriteOperate` role, server-wide |
| Alarm methods | `AlarmAck` role, server-wide |
| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** |
## Key source files
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs``ExecuteAsync` / `ExecuteWriteAsync`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager;
`EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
HistoryRead overrides
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
`Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write
handler is attached per realm
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to
raw + UNS NodeIds
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline
(**not** on the OPC UA read path)
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and
unit-tested but with **zero production consumers**
+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).
+33
View File
@@ -128,6 +128,39 @@ transports. ScadaBridge itself has since closed that gap with this identical
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
later hardening pass. See the design doc's superseded note for the full history.
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
already existed. On an upgraded deployment every `ClusterNode` row therefore carries `NULL`,
central finds **no dial targets**, and flipping `TelemetryDial:Mode` to `Grpc` connects to
nothing. Fresh installs are unaffected. `AkkaPort` did not have this problem only because it is
`NOT NULL` with a default of 4053.
Nothing fails loudly, which is what makes this worth stating up front — the code degrades
gracefully, once per node, throttled:
```
ClusterNode <id> has no GrpcPort; it exposes no telemetry stream and is skipped in this dial refresh
(further skips of this node are silent)
```
Other symptoms: no `ESTABLISHED` sockets on the telemetry port (a `LISTEN` but nothing else in
`docker exec <node> cat /proc/net/tcp6`), and the `/alerts` / `/script-log` pill never turning
live in `Grpc` mode.
**Before flipping any node to `Grpc`**, set each driver node's `GrpcPort` to that node's own
`Telemetry:GrpcListenPort` — via the AdminUI node editor, or directly:
```sql
UPDATE dbo.ClusterNode SET GrpcPort = <that node's Telemetry:GrpcListenPort> WHERE NodeId = '<node>';
```
There is deliberately **no EF data migration** backfilling this. The correct value is that node's
own listener port, which is per-deployment configuration the database cannot derive; a migration
could only invent one, and a *wrong* dial target is worse than the `NULL` the dialer already skips
explicitly. The docker-dev seed does backfill (`GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'`
→ 4056) because there the port is fixed by `docker-compose.yml` and therefore actually knowable.
## Reconnect and reliability
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
+1 -1
View File
@@ -70,7 +70,7 @@ Project root files:
| Capability | Implementation entry point |
|------------|---------------------------|
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
| `IRediscoverable` | `Browse/DeployWatcher.cs` |
| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) |
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
+349
View File
@@ -0,0 +1,349 @@
# MTConnect Driver
Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is
the short path — for the full design rationale read
[`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md)
and the build-vs-plan record in
[`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md); for the
fixture recipe read
[`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
(not duplicated here).
> **Live gate:** see the LIVE-GATE RESULT note in the plan's Task 21
> (`docs/plans/2026-07-24-mtconnect-driver.md`) for the docker-dev `/run` verification outcome
> (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).
## What it talks to
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
front-ends a machine tool (or fronts an adapter that itself talks to the
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
three standard REST paths:
- `/probe` — the static device model (Device → Component → DataItem tree)
- `/current` — a snapshot of every DataItem's latest observation
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
deltas, keyed by a monotonic sequence number
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
because the mainstream Agent surface has no "set value" operation.
## Built vs. planned — read this before trusting the design doc's §2
The design doc ([`2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md))
picked the **TrakHound `MTConnect.NET-Common` / `-HTTP`** NuGet packages (MIT,
`netstandard2.0`) as the primary path, with a hand-rolled fallback. **The
hand-rolled fallback is what shipped.** Task 6/7 of the implementation plan
found by reflection + live invocation that the pinned TrakHound packages
ship **no XML formatter** (`Document Formatter Not found for "xml"` — the
formatter lives in a separate `MTConnect.NET-XML` package the design didn't
pin) and that `MTConnectHttpClientStream` exposes no injectable `HttpClient`
handler, so it cannot be unit-tested behind the driver's `IMTConnectAgentClient`
seam. Both package references were dropped. There is **no TrakHound
dependency anywhere in the shipped driver** — probe/current/sample parsing is
hand-rolled `System.Xml.Linq` plus a multipart boundary reader, entirely
behind `IMTConnectAgentClient`. See the CORRECTION block under Task 0 of the
implementation plan for the full record.
**Namespace-agnostic parsing.** Every parser matches XML elements on
`LocalName` only, ignoring the document's XML namespace entirely, so
MTConnect 1.3 through 2.x documents all parse without a schema-version
branch. This is deliberate, not sloppy: a real Agent injects vendor-extension
`Component`s in a foreign namespace whose child `DataItems` elements inherit
the *default* namespace, and namespace-strict matching would silently drop
the whole vendor component (and every DataItem beneath it) rather than fail
loudly. Attributes are read **unqualified** for the same reason — so an
`xsi:type` attribute is never mistaken for a DataItem's own `type`.
## Project split
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/` | net10.0 | In-process driver — `MTConnectDriver`, the hand-rolled `IMTConnectAgentClient` (probe/current/sample), the observation index, the factory |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/` | net10.0 | `MTConnectDriverOptions`, `MTConnectTagDefinition`, and the pure `MTConnectDataTypeInference` table shared by the driver, browse-commit, and the AdminUI typed editor |
No `.Browser` project — browse comes free from the Wave-0 universal
discovery browser (see [Browse](#browse--free-via-the-universal-browser)
below).
## Capability surface
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
(a request/response handshake, not a "set value" operation), and is out of
scope for this build — see [Deferred](#deferred-not-in-this-build).
| Capability | Path | Notes |
|------------|------|-------|
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
**`IReadable.ReadAsync` is never called by the running server.** The
production data plane is entirely `ISubscribable``DriverInstanceActor`
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
Client CLI (`... read -n ...`) and for the unit/integration suites; it is
correct and tested, just not on the hot path.
## Minimum deployment
```jsonc
"Drivers": {
"mtconnect-1": {
"Type": "MTConnect",
"Config": {
"AgentUri": "http://10.100.0.35:5000",
"DeviceName": null,
"RequestTimeoutMs": 5000,
"SampleIntervalMs": 1000,
"SampleCount": 1000,
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Tags": []
}
}
}
```
`RawTags[]` is not authored by hand — `DriverDeviceConfigMerger` injects it
at deploy time from the tags authored on the `/raw` tree.
### Config keys
Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`,
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`):
| Key | Default | Notes |
|---|---|---|
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
**All timing knobs are validated strictly positive** at `InitializeAsync`
(`RequirePositive`, mirroring the arch-review 01/S-6 lesson: a `0` timeout
does not mean "wait forever," it faults the driver). This applies to
`RequestTimeoutMs`, `HeartbeatMs`, `SampleIntervalMs`, `SampleCount`, and
(when the probe is enabled) `Probe.Interval` / `Probe.Timeout`.
**No save-time gate on a blank `AgentUri` in the AdminUI.** The driver form
renders an inline validation notice (`_form.Validate()`), but
`DriverConfigModal.SaveAsync` has no per-form validation seam to block the
Save button on it — no sibling driver form has one either. A blank
`AgentUri` saves cleanly and fails at deploy with the driver's own error
message, not at authoring time.
## Data plane
### FullName == DataItem `id`
`FullName` (both `MTConnectTagDefinition.FullName` and every `RawTags[]`
blob's identifier) is the MTConnect `DataItem@id` attribute — the value the
universal browser commits and the key the driver resolves reads/subscribes
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
three spellings in order — `fullName``dataItemId``address` — and takes
the first non-blank one, normalising onto `fullName` on save. `address` is
accepted because that's the field name `RawBrowseCommitMapper` writes for a
driver with no typed editor path, so a browse-committed tag stays readable
even before the editor round-trips it.
### Coercion-type precedence
The driver keys its data plane by **RawPath** (the v3 raw-tag identity), not
by DataItem id directly, resolving RawPath → dataItemId internally. Each raw
tag's coercion type (the `DriverDataType` its Agent observation is parsed
into) is resolved in this order, first non-null wins:
1. The `RawTags[]` blob's `driverDataType` or `dataType` field (both
spellings accepted — the typed editor writes `dataType`, the driver's own
`tags[]` shape uses `driverDataType`).
2. A matching `Tags[]` entry naming the same DataItem id.
3. The Agent's own `/probe` declaration, via `MTConnectDataTypeInference.Infer`
(category/type/units/representation → `DriverDataType`).
4. `DriverDataType.String` — the coercion that cannot fail.
### Quality mapping
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
**`BadNoCommunication`** (`0x80310000`). This is deliberately distinct from
the fleet-standard `BadCommunicationError` (`0x80050000`, used elsewhere for
the driver's *own* transport failure to the Agent): `UNAVAILABLE` means the
Agent is reachable and answered, it just has no device-backed value for that
item. A `CONDITION` observation's value is taken from its **element name**
(`<Normal/>` → the string `"Normal"`, `<Fault/>``"Fault"`); a `<Unavailable/>`
condition element normalises onto the same `UNAVAILABLE` sentinel as every
other category so one comparison covers all three.
Other status codes an observation can surface:
| Code | Meaning |
|---|---|
| `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all |
| `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent |
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `TIME_SERIES` vector, or a structured `DATA_SET`/`TABLE` observation (deferred to P1.5; see [Known limitations](#known-limitations)) |
| `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet |
| `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed |
### Subscribe — the `/sample` pump
One shared `/sample` long-poll stream per driver instance (the Agent streams
the whole device model regardless of which subset is subscribed, so
per-tag streams would be wasted round-trips), run under a heartbeat
watchdog — `HttpClient.Timeout` cannot bound a long-lived stream, so a
missing chunk **and** missing keep-alive heartbeat within
`HeartbeatMs × N` is what detects a frozen peer.
Ring-buffer overflow (the Agent's circular observation buffer wrapped past
what the driver's cursor expects) is detected **two ways**, both triggering a
`/current` re-baseline before the stream resumes:
1. `IMTConnectAgentClient.IsSequenceGap` — the next chunk's `firstSequence`
is newer than the driver's expected cursor.
2. An `MTConnectError` document reporting `OUT_OF_RANGE` — real Agents return
this both under HTTP 200 (a normal MTConnect error document) and as a bare
HTTP 400, and the client handles both.
**An Agent `instanceId` change means the Agent restarted**, and is checked
**before** the sequence-gap check (a restart also usually trips the gap, so
the two need disambiguating, not just OR-ing together). On a changed
`instanceId` the driver clears its cached probe model and raises
`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI
`/hosts` page — see [Known limitations](#known-limitations) for what that does
and does not do.
## Browse — free via the universal browser
MTConnect ships **no bespoke browser project**. Setting
`ITagDiscovery.SupportsOnlineDiscovery => true` is the entire integration:
the Wave-0 `DiscoveryDriverBrowser` sees a driver whose `TryCreate` succeeds
and whose instance reports online discovery, renders the AdminUI **Browse**
button, constructs the driver, runs `InitializeAsync` (the `/probe` connect)
+ `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the
throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
committed directly as `TagConfig.FullName` on pick. See
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
## CONDITION modelling (v1: plain String)
Each `CONDITION` DataItem materializes as a `String` variable whose value is
the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`.
There is no native OPC UA Part 9 alarm plumbing in this build;
`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so
the browse side-panel flags it and a future upgrade to native alarms doesn't
need re-authoring. See [Deferred](#deferred-not-in-this-build).
## Known limitations
These are real, not placeholders — read them before relying on the driver
for anything beyond values-and-conditions.
1. **A restarted Agent prompts an operator; it does not re-shape the address
space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
(2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
raw tags through the `/raw` browse-commit flow, so until an operator
re-browses the Agent and commits, any DataItem that appeared or vanished is
not reflected in the served tree. A runtime graft was deliberately rejected
— it would materialise nodes nobody approved and no deployment artifact
records.
**`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has
no production call site and nothing writes a `DriverHostStatus` row. That
half remains a fleet-wide gap affecting every driver that implements it
(Gitea #521).
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
routing.** One driver instance owns exactly one Agent client scoped by
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
never read by the driver. Two `/raw` Devices authored under one MTConnect
driver instance both resolve to the same one Agent connection. Real
per-device routing (a client per device) would be a design change, not a
bug fix.
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
blob's coercion type.** The `/probe`-sourced inference
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
type, but it doesn't close the gap — an authored `DataType` mismatched
against the actual Agent observation still surfaces as `BadTypeMismatch`
/`BadOutOfRange` at runtime rather than at authoring time.
4. **`sampleCount` is not a legal `DataItem` attribute.** It belongs on a
`TIME_SERIES` *observation*, not the device-model declaration. A real
Agent that meets `sampleCount` on a `DataItem` declaration logs `The
following keys were present and not expected: sampleCount` and **drops
the entire data item** from the served model. A live TIME_SERIES tag
therefore always resolves to `ArrayDim = null` (variable-length array) in
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
declare a `sampleCount`, is unrealistic on this point and exists only to
pin the parsing rule itself.
5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above).
6. **`TIME_SERIES` arrays and structured `DATA_SET`/`TABLE` observations
surface as `BadNotSupported`**, not an approximation — see the Quality
mapping table.
## Deferred (not in this build)
- **Write-back (MTConnect Interfaces).** Design
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
the mainstream Agent surface is read-only by design; Interfaces is a rare,
optional request/response handshake that would mislead if modelled as
`IWritable`. Revisit only on a concrete deployment need.
- **P1.5 fast-follow** (design
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
native-alarm pattern); `TIME_SERIES` SAMPLE arrays materialized as real
OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations.
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
auto-discovery (no device model without an Agent in front, so
`SupportsOnlineDiscovery` would have to report `false` and tags would be
authored by hand, like Modbus). Niche; not built.
## Testing
- **Unit tests**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
(canned-XML fixtures, no network) — 491/491 at time of writing. Covers
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
`UNAVAILABLE``BadNoCommunication`, CONDITION state-word mapping,
multipart chunk framing, and ring-buffer re-baseline paging (both the
sequence-gap and the `OUT_OF_RANGE` legs).
- **Integration tests**
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real
Agent. Read the fixture's own
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
for the full recipe — summary only, here:
- Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which
does not exist on Docker Hub (the design's original assumption).
- The stack is **two services**: the Agent plus a stdlib-only SHDR adapter
(`Docker/adapter.py`, on `python:3.13-alpine`). An Agent with no adapter
reports every observation `UNAVAILABLE` forever, which proves nothing.
- Deployed at `/opt/otopcua-mtconnect/` on the shared docker host
(`10.100.0.35`, `project=lmxopcua` label). Endpoint
`http://10.100.0.35:5000`.
- `agent.cfg` must be **pure ASCII** — one non-ASCII byte anywhere,
including a comment, makes the config parser reject the whole file with
a bare `Failed / Stopped at line: N`.
- Bring-up: `lmxopcua-fix sync mtconnect` then `lmxopcua-fix up mtconnect`
(PowerShell helper, Windows-only) — or, directly on the docker host,
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
`docker compose up -d --wait`.
## Further reading
- [`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md) — full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasing
- [`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md) — the executable implementation plan and the task-by-task build record, including where it diverged from the design (TrakHound → hand-rolled)
- [`docs/plans/2026-07-24-driver-expansion-tracking.md`](../plans/2026-07-24-driver-expansion-tracking.md) — Wave-2 program tracking
- [Docker fixture README](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)
+133
View File
@@ -0,0 +1,133 @@
# MQTT test fixture
Coverage map + gap inventory for the MQTT driver's harness.
**TL;DR:** the unit suite (581 tests) carries the mapping, indexing, Sparkplug state-machine and
failure-classification logic; the live suite (15 tests) proves the parts only a real broker can prove
— TLS, real authentication, CA pinning in both directions, retained-message seeding, that a rejected
CONNACK is actually surfaced, and the Sparkplug birth/alias/rebirth/death path against a real
edge-node simulator.
## What the fixture is
`eclipse-mosquitto:2.0.22` plus a second container of the same image running `publisher/publish.sh`
(the image ships `mosquitto_pub`, so no bespoke image is needed), plus — behind the `sparkplug`
compose profile — **`otopcua-sparkplug-sim`**, a project-owned C# Sparkplug-B edge-node simulator
(`tests/Drivers/….Driver.Mqtt.IntegrationTests/SparkplugSimulator/`). Compose lives at
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/`; the deployed stack is
`/opt/otopcua-mqtt` on the shared Docker host.
**Two authenticated listeners, no anonymous fallback** (`allow_anonymous false`) — deliberate: an
anonymous broker would let a broken auth config pass every test.
| Listener | Endpoint | Env var |
|---|---|---|
| TLS + auth | `10.100.0.35:8883` | `MQTT_FIXTURE_ENDPOINT` |
| plaintext + auth | `10.100.0.35:1883` | `MQTT_FIXTURE_PLAIN_ENDPOINT` |
`MqttFixture` gates the suite on `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_USERNAME` /
`MQTT_FIXTURE_PASSWORD` (+ `MQTT_FIXTURE_CA_CERT`, `MQTT_FIXTURE_TOPIC_PREFIX`) and **skips cleanly**
when they are absent, so the suite is safe to run offline.
**Unlike every other fixture it needs generated material first** — `./gen-fixture-material.sh` writes
the gitignored `secrets/` (password file, CA, server cert/key). The broker will not start without it.
Bring-up and the CA-fetch recipe are in [`infra/README.md`](../../infra/README.md) §3.
### Published topics
| Topic | Shape | Retain | Why it exists |
|---|---|---|---|
| `otopcua/fixture/retained/seed` | `{"value":42.5,…}` | ✅ **published exactly once** | The only way to prove retained *seeding*. On a republished topic a retained seed is indistinguishable from a fast live publish |
| `otopcua/fixture/line1/temperature` | `{"value":…,"unit":"C","seq":n}` | ✅ | JSON + JSONPath extraction |
| `otopcua/fixture/line1/counter` | `{"value":n}` | ✅ | Monotonic, for ordering |
| `otopcua/fixture/line1/state` | `RUNNING` (bare text) | ✅ | Non-JSON `Raw`/`Scalar` payloads |
| `otopcua/fixture/noise/chatter` | JSON | ❌ | **Never authored as a tag** — a broker carrying unauthored traffic is the normal case and the driver must stay silent about it |
## What it actually covers
The 7 live tests: TLS+auth connect; connect pinned to the fixture CA; connect pinned to a *foreign*
CA is **rejected** (the falsifiability control — a pinning test that only ever passes proves nothing);
wrong password is rejected **and surfaced** (the MQTTnet-5 silent-`Healthy` defect); plain
subscribe→value under a RawPath; retained message seeds a value on subscribe; and an unauthored topic
stays silent while an authored tag keeps flowing.
### The Sparkplug simulator
Group **`OtOpcUaSim`**; edge nodes **`EdgeA`** and **`EdgeB`**; `EdgeA` additionally publishes device
**`Filler1`**. Node metrics `Temperature` (Float), `Pressure` (Double), `Count` (Int32), `Running`
(Boolean), `Serial` (String) plus the mandatory `bdSeq` and **`Node Control/Rebirth`** — the last one
deliberately carries a `/`, so it exercises the "a metric name is not a topic segment" rule. Device
metrics: `Temperature` (Float), `FillCount` (Int64), `Jammed` (Boolean). Cadence ~2 s.
It **subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}`** and re-births on a rebirth request, so it
exercises the real NCMD round trip rather than simulating it. Births are not retained (per spec), so
`docker restart otopcua-sparkplug-sim` is the way to force a fresh birth on demand:
```bash
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose --profile sparkplug up -d'
ssh dohertj2@10.100.0.35 'docker restart otopcua-sparkplug-sim' # force a birth
ssh dohertj2@10.100.0.35 'docker logs -f otopcua-sparkplug-sim'
```
Live-gated end-to-end (2026-07-24, P1 milestone): deployed on the docker-dev rig against this fixture,
both tags resolved and served changing values through OPC UA, and the AdminUI `#`-observation picker
rendered the real topic tree. Re-gated for P2 (2026-07-25) against the Sparkplug simulator — see
[Mqtt.md](Mqtt.md).
## What it does NOT cover
### 1. Sparkplug primary-host STATE, and write-through
The simulator never observes a Host Application `STATE`, because the driver never publishes one
(`ActAsPrimaryHost` is unimplemented). Nor is there any DCMD/NCMD **command** coverage: rebirth is the
only NCMD the driver ever sends, and `IWritable` is deferred.
Sparkplug **seq-gap injection** is unit-proven only — the simulator always publishes a well-formed
monotonic `seq`, so no live test drives the gap → rebirth path end-to-end.
### 2. Broker-side failure injection
No test kills the broker mid-session, partitions the network, or forces a SUBACK failure against a
real broker. Reconnect, backoff and per-topic degradation are **unit-proven only**.
### 3. Wildcard tags against a live broker
The by-filter indexing fix that keeps wildcard tags alive across a reconnect is covered by unit tests;
no live test authors a `+`/`#` tag and bounces the connection.
### 4. QoS 2 delivery semantics
The fixture publishes at QoS 0/1. QoS 2's exactly-once handshake is never exercised end-to-end.
### 5. Scale
One publisher, five topics, a ~2 s cadence. Nothing probes the 50 000-node browse cap, the 1 MiB
payload cap, or thousands of authored tags.
### 6. Redundant-pair client-id collision
Found by hand during the P1 gate (a fixed `clientId` makes pair nodes evict each other), **not** by a
test. No automated coverage asserts that two concurrent drivers coexist.
## When to trust the MQTT fixture, when to reach for a real broker
| Question | Fixture answers it? |
|---|---|
| Does TLS + auth work, and does bad auth fail loudly? | ✅ yes |
| Is a retained message seeded on subscribe? | ✅ yes |
| Does JSONPath extraction + coercion behave? | ✅ yes (unit + live) |
| Does the driver ignore unauthored traffic? | ✅ yes |
| Does it survive a broker restart / flapping link? | ❌ unit-only |
| Does it hold up at plant tag counts? | ❌ no |
| Does a Sparkplug birth → alias → DATA → death cycle work? | ✅ yes (live, vs. the simulator) |
| Does an NCMD rebirth request actually get answered? | ✅ yes (the simulator subscribes + re-births) |
| Sparkplug seq-gap → rebirth | ❌ unit-only (the sim never skips a seq) |
| Sparkplug primary-host STATE / DCMD writes | ❌ not implemented |
## Follow-up candidates
1. Broker-restart / SUBACK-failure live tests (closes gap 2).
2. A live wildcard-across-reconnect test (closes gap 3).
3. A concurrent-two-driver test pinning the client-id hazard (closes gap 6).
4. A seq-gap injection switch on the simulator, closing the last unit-only Sparkplug leg.
## Key fixture / config files
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml`
- `tests/Drivers/…/Docker/mosquitto.conf` · `Docker/gen-fixture-material.sh` · `Docker/publisher/publish.sh`
- `tests/Drivers/…/MqttFixture.cs` · `PlainMqttLiveTests.cs`
- [`infra/README.md`](../../infra/README.md) §3 — bring-up, endpoints, CA fetch
+397
View File
@@ -0,0 +1,397 @@
# MQTT Driver
In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker,
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`.
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
protobuf under one `spBv1.0/{GroupId}/#` subscription. Both run over the same single client.
See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and
[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md)
for the design.
> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
> **deferred** ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)); every MQTT node
> materializes read-only. See [Known gaps](#known-gaps) — every entry there carries a tracking issue
> (#507#515).
## Project Layout
| Project | Holds |
|---|---|
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` |
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
> `.Browser` references `.Driver` (not just `.Contracts`) — a **documented, deliberate exception**
> so browse can reuse `MqttConnection.BuildClientOptions` (TLS / CA-pin / credentials / protocol
> mapping). Its cost is pulling `Core` into the AdminUI graph. The csproj carries a boxed warning;
> do not copy the pattern into a new driver's browser. The clean fix — extract the pure
> options builder down into `.Contracts` — is tracked as
> [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512).
## Capability Surface
```csharp
public sealed class MqttDriver
: IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
```
| Capability | Entry point | Notes |
|---|---|---|
| `IDriver` | `InitializeAsync` / `ReinitializeAsync` / `ShutdownAsync` / `GetHealth` | Order **register → attach → connect** is contractual. `ReinitializeAsync` applies a tag-only delta in place, and rebuilds the session when the broker identity changed |
| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`. `RediscoverPolicy` is `Once` in Plain and **`UntilStable` in Sparkplug** (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth) |
| `ISubscribable` | `SubscribeAsync` / `OnDataChange` | One SUBSCRIBE per distinct authored topic. **`publishingInterval` is ignored** — MQTT is push-based; this driver neither polls nor throttles |
| `IReadable` | `ReadAsync` | Serves the last received/retained value from `LastValueCache`; never throws. A never-seen tag reads `BadWaitingForInitialData` |
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
| `IRediscoverable` | `OnRediscoveryNeeded` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false`
*"MQTT ingest is one-way … ViewOnly rather than an Operate node whose writes would silently vanish."*
Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
## Configuration
Bound through `MqttJson.Options`**case-insensitive**, enums by **name**, unknown members skipped.
**The whole connection is authored on the DRIVER** (`DriverConfig`), via `MqttDriverForm` in the `/raw`
driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does **not** use the v3
endpoint→`DeviceConfig` split: an MQTT driver instance holds exactly one broker session, so
`MqttDeviceForm` is informational (the same shape as `GalaxyDeviceForm`) and devices under an MQTT
driver are pure organisational grouping in the RawPath.
> **Why not the device.** `DriverDeviceConfigMerger` merges a device's keys up to the top level *only
> when the driver has exactly one device*. A device-authored broker connection therefore disappears
> from the merged blob the moment a second device is added — a silent outage with nothing to see at
> deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put
> the connection on a sole device still works (merge-up wins over the driver's keys) and
> `MqttDeviceForm` flags those keys so the mismatch is visible; move them to the driver.
`DriverDeviceConfigMerger` still merges driver + device and injects `rawTags`.
```jsonc
// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
{
"Host": "10.100.0.35",
"Port": 8883,
"UseTls": true,
"AllowUntrustedServerCertificate": false,
"CaCertificatePath": null, // pins the chain; null = OS trust store
"Username": "otopcua",
"Password": "", // never commit
"ProtocolVersion": "V500",
"CleanSession": true,
"KeepAliveSeconds": 30,
"ConnectTimeoutSeconds": 15,
"ReconnectMinBackoffSeconds": 1,
"ReconnectMaxBackoffSeconds": 30,
"MaxPayloadBytes": 1048576,
"Mode": "Plain",
"Plain": { "TopicPrefix": "", "DefaultQos": 1 }
}
// DeviceConfig — empty; MQTT has no per-device endpoint.
{}
```
### `Sparkplug` sub-object (`Mode: "SparkplugB"`)
```jsonc
{
"Mode": "SparkplugB",
"Sparkplug": {
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
"HostId": "", // Host Application identity; receive-only (see below)
"ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
}
}
```
| Key | Default | Notes |
|---|---|---|
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
`MqttDriverForm` authors all five in Sparkplug mode. The sub-object is **merged, never replaced** (a
key a newer driver adds inside it survives an older AdminUI), and in `Plain` mode it is not touched at
all — so flipping a driver to Plain to look at it and back does not lose the group id. The form never
writes `RawTags`; the deploy artifact owns it.
> **This was a live-gate finding.** Through the P2 implementation `MqttDriverForm` still carried its P1
> placeholder — switching Mode to `SparkplugB` rendered a "not available yet" notice and **no group-id
> field**, so once Sparkplug ingest shipped there was no way to author a Sparkplug driver from the
> AdminUI at all. Pinned by `MqttDriverFormModelTests`' Sparkplug cases.
Every key has a default, so nothing is strictly required by the binder. Notable ones:
`port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**,
`keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**,
`reconnectMinBackoffSeconds` **1** / `reconnectMaxBackoffSeconds` **30**,
`maxPayloadBytes` **1 MiB**, `allowUntrustedServerCertificate` **false**.
> ⚠️ **Leave `clientId` unset on a redundant pair.** Both nodes of a pair run the driver. MQTT
> requires client ids to be unique per broker, so a *fixed* `clientId` makes the two nodes evict each
> other forever — the broker logs `Client <id> already connected, closing old connection` and both
> nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet
> generate a unique id per connection, which is correct. This was observed live during the P1 gate.
> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) `MqttDriverForm`
> defaults the field blank and raises this warning inline the moment a value is typed.
The form validates every knob against the driver's own `[Range]` bounds (port 165535, keep-alive /
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 02) and
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
a `connectTimeoutSeconds: 0` driver-brick.
## Tag Configuration
A tag binds in **one of two shapes**, and which one is decided by *which keys are present*, not by a
`mode` key on the tag. `MqttTagDefinitionFactory` reads the Sparkplug tuple when any Sparkplug
descriptor key is set, and the topic otherwise; the AdminUI's "Tag shape" selector infers the same way
on reopen. The key names are single-sourced in `MqttTagConfigKeys` so the browse-commit mapper and the
factory cannot drift.
### Plain shape
| Key | Type | Default | Notes |
|---|---|---|---|
| `topic` | string | — | **Required.** Absent/blank rejects the tag ⇒ `BadNodeIdUnknown` |
| `payloadFormat` | `Json` \| `Raw` \| `Scalar` | `Json` | **Strict** — a typo'd value rejects the tag |
| `jsonPath` | string | `"$"` | Absent *or empty* ⇒ document root. `Json` only |
| `dataType` | `DriverDataType` | `String` | **Strict.** `Float64`, not `Double` — see below |
| `qos` | int 02 | driver's `defaultQos` | **Strict** — a malformed value rejects rather than silently weakening delivery |
| `retainSeed` | bool | `true` | Seed this tag from the broker's retained message on subscribe |
There is **no `mode` key** — the AdminUI's "Tag shape" selector is UI-only, inferred from the blob and
never serialized.
**`dataType` uses `DriverDataType`, which has `Float64` and has no `Double`.** Authoring `"Double"` is
a strict-enum rejection, not a fallback.
Payload handling: `Json` selects via JSONPath then coerces (miss ⇒ `BadDecodingError`, coerce fail ⇒
`BadTypeMismatch`); `Raw` returns the payload's UTF-8 text verbatim and **ignores `dataType`**;
`Scalar` decodes then coerces. The JSONPath subset is `$`, `$.a.b`, `$['a']`, `$.a[0]` — **no
filters, slices, wildcards or recursive descent**.
### Wildcards
The **runtime accepts** a `+`/`#` topic (it is ambiguous, not unparseable) and the deploy-time
`Inspect` pass **warns**. The **AdminUI editor rejects** it outright — the one place the editor is
deliberately stricter than the driver, because one Tag holds one value and a wildcard would feed it
from many source topics. Wildcard tags are indexed **by filter**, not by concrete topic, so they
survive a reconnect; a message fans out to *every* matching tag, and unauthored topics are dropped.
### Sparkplug shape
| Key | Type | Default | Notes |
|---|---|---|---|
| `groupId` | string | — | **Required.** Must match the driver's `Sparkplug.GroupId` to ever receive a message |
| `edgeNodeId` | string | — | **Required.** |
| `deviceId` | string | *absent* | **Omit** for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from `""` |
| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`**`Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split |
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
`payloadFormat`, `jsonPath`, `qos` and `retainSeed` are **Plain-only** and meaningless here: a
Sparkplug body is protobuf, and the subscription is one driver-wide `spBv1.0/{GroupId}/#`, not a
per-tag filter.
`groupId` / `edgeNodeId` / `deviceId` are literal MQTT topic segments, so the AdminUI editor refuses
`/`, `+` and `#` in them — a decoded incoming id can never contain one, so such a tag could never
bind. `metricName` is deliberately **exempt**: it is not a topic segment.
```jsonc
// A device metric.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"FillCount"}
// A node-level metric — note deviceId is ABSENT, not blank.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}
```
## Sparkplug B Ingest
One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
(`SparkplugIngestor` + `SparkplugCodec` / `AliasTable` / `BirthCache` / `SequenceTracker`):
- **Births are the only self-describing message.** NBIRTH/DBIRTH name every metric and assign its
alias; NDATA/DDATA carry an **alias and no name at all**. So the alias table is rebuilt *per birth*
and tags bind **by name**, never by alias — aliases are per-birth and may be reused for a different
metric after a rebirth.
- **NBIRTH is node-scoped, DBIRTH is device-scoped**, and routing a DBIRTH through the node path
wipes `bdSeq`. They are handled separately on purpose.
- **Sequence tracking**: `seq` is a wrapping 0255 counter. A detected gap raises a rebirth request
when `RequestRebirthOnGap` is on, rather than silently continuing on stale metric state.
- **Death → STALE.** An NDEATH whose `bdSeq` matches the live session (the bdSeq tie is what stops a
stale death from killing a fresh session) marks every metric under that edge node `Bad` /
`BadNoCommunication`. A DDEATH does the same for one device. The next birth restores them.
- **Unsupported datatypes are skipped with a warning, never guessed**: `DataSet`, `Template`,
`PropertySet`, `PropertySetList`, `Unknown`. `Int8``Int16` and `UInt8``UInt16` widen; `*Array`
variants map to the element type with the array bit set.
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
must not carry a previous session's alias table into a new one.
### Rediscovery (Sparkplug)
`OnRediscoveryNeeded` fires only when a birth is **for an authored scope** *and* its metric-name set
**differs** from the last one seen for that scope (order-insensitive, ordinal). Both conditions are
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
> driver's decision is observable only as an Information log line
> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
## Connection + Failure Semantics
`MqttConnectionState`: `Disconnected → Connected → Reconnecting → Faulted → Disposed`.
A broker that is merely down stays **`Reconnecting` indefinitely** — unreachable is never a fault.
Backoff is exponential between the two `reconnectBackoff` bounds.
**MQTTnet 5 does not throw on a rejected CONNACK** — it returns the code and leaves the client
disconnected. Left unhandled, a wrong password produced a `Healthy` driver that never received a
value. `MqttConnection` therefore raises `MqttConnectRejectedException`:
- **Unrecoverable ⇒ `Faulted`, supervisor stops** (retrying cannot help): `MalformedPacket`,
`ProtocolError`, `UnsupportedProtocolVersion`, `ClientIdentifierNotValid`, `BadUserNameOrPassword`,
`NotAuthorized`, `Banned`, `BadAuthenticationMethod`, `TopicNameInvalid`, `PacketTooLarge`,
`PayloadFormatInvalid`, `RetainNotSupported`, `QoSNotSupported`, `ServerMoved`.
- **Everything else is transient** and keeps retrying — including `UnspecifiedError` and any future
code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.)
A reconnect that completes without re-subscribing would be a healthy-looking connection that receives
nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
`BadCommunicationError`.
## Browse
`MqttDriverBrowser` opens a **read-only observation session**: it subscribes at QoS 0 to `#` (or
`{topicPrefix}/#`) under a `-browse-`-suffixed client id and builds a `/`-segment tree from whatever
publishes. **Nothing is ever published.** It is *inherently incomplete* — a topic that stays silent
during the window is invisible — so manual entry remains the escape hatch.
Bounds: 50 000 nodes (then `⚠ Observation truncated`), 1024-char topics / 256-char segments (then
`⚠ Some topics were too long`), 512-byte payload inspection. Sessions are reaped after 2 minutes idle.
**Sparkplug B browse serves a different tree from the same passive window**: observed NBIRTH/DBIRTH
certificates are decoded into `Group → EdgeNode → [Device] → Metric` (a birth is the only Sparkplug
message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.
The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway
(`SupportsOnlineDiscovery` is `false`).
### Browse-commit writes the address the factory reads
A committed leaf's address is a **descriptor**, not a single reference string, so
`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
**It is never parsed out of the browse node id.** A Sparkplug node id is
`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
(`Node Control/Rebirth`), so splitting the id cannot recover the tuple. Which keys the session emits
is also how the mapper learns the shape — the driver *type* reaching it is just `Mqtt`, and the mode
lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is
refused **at commit, in words**, rather than committed as a tag that deploys clean and then reports
`BadNodeIdUnknown` forever.
### Request rebirth (Sparkplug only)
Births are never retained, so a healthy but quiet plant shows an **empty** picker tree until one
lands. The `Request rebirth` affordance in the browse modal is the way to fill it on demand: it
publishes a Sparkplug rebirth-request NCMD (`Node Control/Rebirth`) to the selected scope — the **one
and only** browse action that publishes anything.
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
beneath it and is refused **whole** past 32, publishing nothing.
- **Two clicks, never one**`Request rebirth…` arms a confirm panel naming the resolved scope and
its consequence; changing the selection disarms it.
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
(fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy,
over-wide group, unresolvable scope — is **shown**, not swallowed.
- Offered only for a Sparkplug session (`IRebirthCapableBrowseSession.RebirthAvailable`); a Plain
MQTT window publishes nothing, ever.
### Refresh
An observation window **accumulates**, but the tree renders **once**, when it is created — and
`OpenAsync` returns as soon as the SUBSCRIBE is granted, without waiting for the birth-observation
window. So the first render is normally empty on Plain (a topic that has not published yet is
invisible) and *almost always* empty on Sparkplug (births are never retained). **Refresh** re-reads
the root against the same session: nothing reconnects, nothing already observed is lost, and the tag
selection is kept. Only the armed rebirth scope is cleared, because the node it named may no longer be
in the rebuilt tree.
> **Also fixed by this gate, and not MQTT-specific:** every node label in the shared
> `DriverBrowseTree` was an `<a href="#">`. `blazor.web.js`'s enhanced-navigation click interceptor
> resolves a bare `#` against `<base href="/">`, so clicking a node **label** navigated the whole
> AdminUI to `/` and destroyed the hosting modal (`@onclick:preventDefault` suppresses the browser's
> default action, not Blazor's interceptor). The labels are now `<button type="button">`. This
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
disconnects. Success message: `"MQTT CONNECT OK"`. A rejected CONNACK is described by the same
`DescribeConnackRejection` the running driver uses, so the button and the driver report a rejection in
identical words. See [TestConnectProbes.md](TestConnectProbes.md).
## Testing
- **Unit**`tests/Drivers/…Driver.Mqtt.Tests` (**581 tests**): tag-config mapping (both shapes),
JSONPath subset, strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case),
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
rediscovery change gate, and the browse session's "publishes nothing" assertion.
- **Live (env-gated)**`tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
- **AdminUI**`MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
only ever proven by a live `/run` gate.
## Operational Notes
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
**rebirth requests only** — never to command a device.
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
- **Unauthored traffic is ignored.** A busy broker is normal; the driver never auto-provisions a tag.
## Known gaps
Every row is tracked. None is a silent failure — each either refuses in words or logs.
| Gap | Issue | Effect | Notes |
|---|:---:|---|---|
| **Write-through (`IWritable`)** | [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508) | Every MQTT node is read-only | Deferred to P3 — NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo |
| **Rediscovery is inert** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) | A DBIRTH introducing a metric does not change the OPC UA tree; redeploy to pick it up | v3 **platform** gap, not MQTT-specific — nothing subscribes to `OnRediscoveryNeeded` and `DriverHostActor.HandleDiscoveredNodes` hard-returns. Also affects Galaxy / TwinCAT / AbCip. Driver-side decision is log-observable |
| **Rebirth needs a non-empty tree** | [#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514) | `Request rebirth…` cannot be armed on a plant that has not birthed since the window opened | See [Refresh](#refresh). Session side already supports a bare `{group}/{edgeNode}` scope |
| **`_canRebirth` is captured at browse-open** | [#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510) | A session reaped for idleness (2 min) still draws the button, and using it errors | The error is shown, not swallowed; reopen the browser |
| **Primary-host STATE publishing** | [#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511) | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
| **A metric whose name contains `/` cannot be browse-committed** | [#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509) | The commit is refused **whole**, in words (`Row N: Name must not contain '/'`) | The derived **tag Name** is a RawPath segment and may not contain `/`, while `metricName` legitimately may. This blocks the spec-mandatory `Node Control/Rebirth`. **Workaround: Manual entry** — author the tag with a `/`-free name and type the slashed `metricName` into the Sparkplug editor, which accepts it. Nothing is silently mis-bound |
| **`DataSet` / `Template` / `PropertySet` metrics** | [#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515) | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
| **The tag editor writes `payloadFormat` into a Sparkplug blob** | [#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513) | Cosmetic only | `payloadFormat` is Plain-only; the factory routes on the Sparkplug keys and ignores it. Noise in the blob, not a mis-binding |
| **`.Browser` references `.Driver`, not just `.Contracts`** | [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512) | A documented layering exception | Taken to reuse `MqttConnection.BuildClientOptions` rather than duplicate TLS/CA-pin logic — see the note at the top of this doc |
+1 -1
View File
@@ -124,7 +124,7 @@ ConditionType events (non-base `BaseEventType`) is not verified.
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
but not exchanged against a server that actually enforces each.
- LDAP-backed `UserName` (matching this repo's server-side
`LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
`LdapOpcUaUserAuthenticator`) requires a live LDAP round-trip; not tested.
## When to trust OpcUaClient tests, when to reach for a server
+20 -5
View File
@@ -1,6 +1,6 @@
# Drivers
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and namespace kinds (Equipment + SystemPlatform). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and the two v3 OPC UA namespaces (Raw + UNS — see `V3NodeIds` / `AddressSpaceRealm`). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
- `IDriver` — lifecycle (`InitializeAsync`, `ReinitializeAsync`, `ShutdownAsync`, `GetHealth`)
- `IReadable` / `IWritable` — one-shot reads and writes
@@ -15,20 +15,28 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`). The registry records each type's allowed namespace kinds (`Equipment` / `SystemPlatform` / `Simulated`), its JSON Schema for `DriverConfig` / `DeviceConfig` / `TagConfig` columns, and its stability tier per [docs/v2/driver-stability.md](../v2/driver-stability.md).
Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs`) — all 12 in one place, `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`. Type names are the `DriverTypeNames` constants (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`), guarded bidirectionally by `DriverTypeNamesGuardTests`.
> ⚠️ **`DriverTypeRegistry` / `DriverTypeMetadata` are vestigial.** No production code constructs or reads them — the class is referenced only by its own unit tests. Its `AllowedNamespaceKinds` field was retired along with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`), and the `SystemPlatform` namespace kind no longer exists.
>
> **Stability tier** likewise does not come from that registry: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C-only protections described in [docs/v2/driver-stability.md](../v2/driver-stability.md) — `MemoryRecycle` hard-breach kill (`MemoryRecycle.cs:54`) and `ScheduledRecycleScheduler` (`:43`) — are therefore dormant for every driver.
## Ground-truth driver list
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|--------|--------------|:----:|----------------|--------------|---------------|
| [Galaxy](Galaxy.md) | `Driver.Galaxy` (+ `.Browser`, `.Contracts`) | A | gRPC to the external `mxaccessgw` gateway (the gateway owns MXAccess COM + the Galaxy Repository SQL reader) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IRediscoverable, IHostConnectivityProbe | In-process .NET 10 driver — the COM bitness constraint lives in the gateway's x86 net48 worker, not here. PR 7.2 retired the legacy in-process `Galaxy.{Shared, Host, Proxy}` + named-pipe Windows service. Native MxAccess alarms work end-to-end |
| [Modbus TCP](Modbus.md) | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
| [Modbus TCP / RTU-over-TCP](Modbus.md) | `Driver.Modbus` (+ `.Addressing`, `.Contracts`) | A | NModbus-derived in-house client; `Transport` selects MBAP (TCP) or RTU CRC-16 framing over a socket | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver. `Transport=RtuOverTcp` (`ModbusTransportMode`, default `Tcp`) targets serial→Ethernet gateways via `ModbusRtuOverTcpTransport` + `ModbusRtuFraming` + `ModbusCrc`, with `ModbusTransportDesyncException` carrying distinct CrcMismatch/UnitMismatch reasons; **direct-serial transport is descoped**. Merged `0f38f486` (#495) |
| [Siemens S7](S7.md) | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
| [AB CIP](AbCip.md) | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
| [AB Legacy](AbLegacy.md) | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
| [TwinCAT](TwinCAT.md) | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange``IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
| **Sql** (no dedicated page — see [design](../plans/2026-07-15-sql-poll-driver-design.md)) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
| **Calculation** (see [Raw.md](../Raw.md#the-calculation-driver)) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). ⚠️ **No typed config form**`DriverConfigModal` has no `Calculation` case, so `RunTimeout` is currently unauthorable through the AdminUI |
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the sole historian backend. Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
## Per-driver documentation
@@ -40,13 +48,18 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
- **FOCAS** has a short getting-started doc because the backend-selection env var + alarm projection opt-in need explaining up front:
- [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting
- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, and **OPC UA Client** each have a per-driver overview page:
- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, **OPC UA Client**, and **MQTT** each have a per-driver overview page:
- [Modbus.md](Modbus.md) — in-process Modbus-TCP driver: address formats, polled subscription model, DL205 octal mapping
- [AbCip.md](AbCip.md) — AB CIP / EtherNet-IP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix): tag discovery, UDT resolution, alarm source
- [AbLegacy.md](AbLegacy.md) — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list
- [S7.md](S7.md) — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model
- [TwinCAT.md](TwinCAT.md) — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
- [Mqtt.md](Mqtt.md) — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding, `#`-observation browser, CONNACK-rejection semantics, **Sparkplug B** (birth/alias/seq/rebirth state machine, birth-driven browse picker, death→STALE), and the **Known gaps** table
- **MTConnect** has a short getting-started doc because the hand-rolled-vs-TrakHound divergence, the
data-plane precedence rules, and a non-trivial known-limitations list need explaining up front:
- [MTConnect.md](MTConnect.md) — Agent config, `FullName`==DataItem `id`, `UNAVAILABLE``BadNoCommunication`, CONDITION-as-`String`, browse-via-universal-browser, fixture recipe, deferred write-back
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
@@ -64,12 +77,14 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
- [MTConnect](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — Dockerized `mtconnect/agent` + stdlib SHDR adapter (two services, the Agent alone reports everything `UNAVAILABLE`); 12/12 against the real Agent, canned XML unit fixtures cover the bulk (491/491)
## Related cross-driver docs
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, and TwinCAT have no alarm source.
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
+2
View File
@@ -49,6 +49,7 @@ with a human-readable explanation rather than a false-green TCP-open tick.
| **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync`. See [degrade semantics](#twincat-degrade) below. | `"ADS state: {state}"` | Deferred — no ADS target |
| **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB |
| **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) |
| **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) |
**Historian.Wonderware** had a TCP `Hello``HelloAck` handshake probe before Phase 5, but the
Wonderware historian backend (and its driver-type / probe) has since been **retired** — the historian
@@ -133,6 +134,7 @@ above. (Live verification on `10.100.0.48:5120` with no key returns
| S7 | Verified | python-snap7 `10.100.0.35:1102` |
| AbCip | Verified | CIP sim `10.100.0.35:44818` |
| Galaxy | Verified | mxaccessgw `10.100.0.48:5120`; `Unauthenticated` reply counts as Ok |
| MQTT | Verified | Mosquitto `10.100.0.35:8883`; live suite covers TLS+auth Ok, foreign-CA rejection, and wrong-password rejection surfacing rather than reporting healthy |
| AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip |
| TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested |
| FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path |
+11 -4
View File
@@ -92,7 +92,7 @@ discovery.
| `ISubscribable` | native ADS notifications (default), poll fallback | `UseNativeNotifications=true` registers device notifications so the PLC pushes changes; `false` uses the shared `PollGroupEngine` |
| `IHostConnectivityProbe` | per-device probe loop | One `HostConnectivityStatus` per configured device; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
| `IPerCallHostResolver` | `ResolveHost` lookup in the tag map | Routes each call to the device of the referenced tag; returns an empty-string sentinel when unresolved |
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded` so the address space is rebuilt |
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded`. ⚠️ **No consumer wires it today** ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) — the address space does **not** rebuild; a config redeploy is required. Driver-side behaviour is log-observable only |
### Rediscovery on PLC re-download
@@ -100,8 +100,15 @@ discovery.
`DeviceSymbolVersionInvalid` (1809 / 0x0711) — the documented TwinCAT
symbol-version-changed signal, raised when a PLC program is re-downloaded —
every symbol and notification handle is invalidated. The driver raises
`OnRediscoveryNeeded` with a `TwinCAT` scope hint so Core rebuilds the address
space rather than treating it as a transient connection error.
`OnRediscoveryNeeded` with a `TwinCAT` scope hint, so that the signal is treated
as an address-space change rather than a transient connection error.
⚠️ **That is the intended design, not present behaviour.** No consumer subscribes
to `OnRediscoveryNeeded` anywhere in `src/`, and the scope hint is read by nothing
([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
[#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)). Today the
signal is observable only in the driver log; after a PLC re-download the served
address space changes only on a config redeploy.
### Native notifications
@@ -148,7 +155,7 @@ fixture is down during normal dev.
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
arrays, per-element historization. `IRediscoverable` still fires and rebuilds array nodes
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
on PLC re-download (unchanged semantics from scalar nodes).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
@@ -1,18 +1,116 @@
{
"planPath": "docs/plans/2026-06-12-historian-tcp-transport.md",
"tasks": [
{"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"},
{"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]},
{"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]},
{"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]},
{"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]},
{"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]},
{"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]},
{"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]},
{"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]},
{"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]},
{"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]},
{"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]}
{
"id": 0,
"nativeTaskId": 296,
"subject": "Task 0: Create feature branch",
"status": "completed"
},
{
"id": 1,
"nativeTaskId": 297,
"subject": "Task 1: Add TCP/TLS fields to client options",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"nativeTaskId": 298,
"subject": "Task 2: Client TCP connect factory + FrameChannel rename",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"nativeTaskId": 299,
"subject": "Task 3: Switch client default ctor to TCP",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 4,
"nativeTaskId": 300,
"subject": "Task 4: Host config binding (Host/Port/TLS)",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 5,
"nativeTaskId": 301,
"subject": "Task 5: Sidecar TcpFrameServer",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 6,
"nativeTaskId": 302,
"subject": "Task 6: Sidecar Program.cs \u2014 TCP bootstrap + env",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 7,
"nativeTaskId": 303,
"subject": "Task 7: Remove dead pipe code + finalize options shape",
"status": "completed",
"blockedBy": [
3,
4,
6
]
},
{
"id": 8,
"nativeTaskId": 304,
"subject": "Task 8: Deploy scripts \u2014 env block + firewall + cert",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 9,
"nativeTaskId": 305,
"subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 10,
"nativeTaskId": 306,
"subject": "Task 10: Docs \u2014 TCP transport",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 11,
"nativeTaskId": 307,
"subject": "Task 11: Verification (build + test + live)",
"status": "completed",
"blockedBy": [
8,
9,
10
]
}
],
"lastUpdated": "2026-06-12"
"lastUpdated": "2026-07-27",
"closureNote": "OBSOLETE \u2014 targets the bespoke Wonderware TCP/ArchestrA historian sidecar, retired when HistorianGateway became the sole historian backend. There is no Wonderware backend in the tree. Closed as obsolete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -1,18 +1,111 @@
{
"planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md",
"tasks": [
{"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"},
{"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]},
{"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]},
{"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]},
{"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]},
{"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]},
{"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]},
{"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]},
{"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]},
{"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]},
{"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]},
{"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]}
{
"id": 402,
"subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1",
"status": "completed"
},
{
"id": 403,
"subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 404,
"subject": "SP Task 2: Phase 0 \u2014 fix docs/security.md + benign-residue comments",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 405,
"subject": "SP Task 3: Phase 0 \u2014 mark shipped .tasks.json completed",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 406,
"subject": "SP Task 4: Phase 1 H1a \u2014 Phase7Applier rebuilds on Changed*",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 407,
"subject": "SP Task 5: Phase 1 H1b \u2014 VirtualTagHostActor respawns changed child",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 408,
"subject": "SP Task 6: Phase 1 H5a \u2014 EquipmentVirtualTagPlan.Historize + composer",
"status": "completed",
"blockedBy": [
402
]
},
{
"id": 409,
"subject": "SP Task 7: Phase 1 H5b \u2014 DeploymentArtifact decodes Historize (byte-parity)",
"status": "completed",
"blockedBy": [
408
]
},
{
"id": 410,
"subject": "SP Task 8: Phase 1 H5c \u2014 VirtualTagHostActor invokes IHistoryWriter",
"status": "completed",
"blockedBy": [
407,
409
]
},
{
"id": 411,
"subject": "SP Task 9: Phase 1 H5d \u2014 thread IHistoryWriter through DriverHostActor + DI",
"status": "completed",
"blockedBy": [
410,
403
]
},
{
"id": 412,
"subject": "SP Task 10: Phase 1 \u2014 docs + follow-up bookkeeping",
"status": "completed",
"blockedBy": [
411
]
},
{
"id": 413,
"subject": "SP Task 11: Phase 0+1 \u2014 full build + test + integration review",
"status": "completed",
"blockedBy": [
403,
404,
405,
406,
407,
408,
409,
410,
411,
412
]
}
],
"lastUpdated": "2026-06-15"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -2,15 +2,72 @@
"planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md",
"branch": "feat/stillpending-phase-2-servicelevel",
"tasks": [
{"id": 414, "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", "status": "pending"},
{"id": 415, "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", "status": "pending", "blockedBy": [414]},
{"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]},
{"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]},
{"id": 418, "subject": "P2 Task 4: PeerProbeSupervisor — one peer probe per driver peer", "status": "pending"},
{"id": 419, "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", "status": "pending", "blockedBy": [417, 418]},
{"id": 420, "subject": "P2 Task 6: docs/Redundancy.md — calculator is WIRED", "status": "pending"},
{"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]},
{"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]}
{
"id": 414,
"subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster",
"status": "completed"
},
{
"id": 415,
"subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)",
"status": "completed",
"blockedBy": [
414
]
},
{
"id": 416,
"subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)",
"status": "completed",
"blockedBy": [
415
]
},
{
"id": 417,
"subject": "P2 Task 3: HealthTick \u2014 periodic DB Ask/PipeTo + PreStart immediate refresh",
"status": "completed",
"blockedBy": [
416
]
},
{
"id": 418,
"subject": "P2 Task 4: PeerProbeSupervisor \u2014 one peer probe per driver peer",
"status": "completed"
},
{
"id": 419,
"subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)",
"status": "completed",
"blockedBy": [
417,
418
]
},
{
"id": 420,
"subject": "P2 Task 6: docs/Redundancy.md \u2014 calculator is WIRED",
"status": "completed"
},
{
"id": 421,
"subject": "P2 Task 7: Full build + test + final integration review",
"status": "completed",
"blockedBy": [
419,
420
]
},
{
"id": 422,
"subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)",
"status": "completed",
"blockedBy": [
421
]
}
],
"lastUpdated": "2026-06-15"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -2,16 +2,80 @@
"planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md",
"branch": "feat/stillpending-phase-3-opcua-standards",
"tasks": [
{"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"},
{"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"},
{"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]},
{"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"},
{"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]},
{"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]},
{"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]},
{"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"},
{"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]},
{"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]}
{
"id": 423,
"subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping",
"status": "completed"
},
{
"id": 424,
"subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)",
"status": "completed"
},
{
"id": 425,
"subject": "P3 Task 3: H4 \u2014 wire OnEnableDisable over OPC UA",
"status": "completed",
"blockedBy": [
424
]
},
{
"id": 426,
"subject": "P3 Task 4: H6b \u2014 AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource",
"status": "completed"
},
{
"id": 427,
"subject": "P3 Task 5: H6c \u2014 NativeAlarmAckRouter seam + native OnAcknowledge routing",
"status": "completed",
"blockedBy": [
424
]
},
{
"id": 428,
"subject": "P3 Task 6: H6d \u2014 DriverHostActor inverse map + native-ack route to driver",
"status": "completed",
"blockedBy": [
426,
427
]
},
{
"id": 429,
"subject": "P3 Task 7: H6e \u2014 wire NativeAlarmAckRouter in host DI",
"status": "completed",
"blockedBy": [
427,
428
]
},
{
"id": 430,
"subject": "P3 Task 8: Docs \u2014 Enable/Disable + native-ack\u2192AVEVA + HistoryUpdate bit",
"status": "completed"
},
{
"id": 431,
"subject": "P3 Task 9: Full build + test + final integration review",
"status": "completed",
"blockedBy": [
423,
425,
429,
430
]
},
{
"id": 432,
"subject": "P3 Task 10: Live /run \u2014 H4 Enable/Disable + H6 native-ack route",
"status": "completed",
"blockedBy": [
431
]
}
],
"lastUpdated": "2026-06-15"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -2,14 +2,68 @@
"planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md",
"branch": "feat/stillpending-phase-4-driver-datatypes",
"tasks": [
{"id": 433, "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", "status": "pending"},
{"id": 434, "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", "status": "pending"},
{"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]},
{"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"},
{"id": 437, "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", "status": "pending"},
{"id": 438, "subject": "P4 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [433, 434, 435, 436, 437]},
{"id": 439, "subject": "P4 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [433, 434, 435, 436, 437, 438]},
{"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]}
{
"id": 433,
"subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)",
"status": "completed"
},
{
"id": 434,
"subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)",
"status": "completed"
},
{
"id": 435,
"subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)",
"status": "completed",
"blockedBy": [
434
]
},
{
"id": 436,
"subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)",
"status": "completed"
},
{
"id": 437,
"subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)",
"status": "completed"
},
{
"id": 438,
"subject": "P4 Task 6: Docs + bookkeeping",
"status": "completed",
"blockedBy": [
433,
434,
435,
436,
437
]
},
{
"id": 439,
"subject": "P4 Task 7: Full build + test + final integration review",
"status": "completed",
"blockedBy": [
433,
434,
435,
436,
437,
438
]
},
{
"id": 440,
"subject": "P4 Task 8: Live /run \u2014 Modbus Int64 (acceptance gate)",
"status": "completed",
"blockedBy": [
439
]
}
],
"lastUpdated": "2026-06-16"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -4,16 +4,61 @@
"designCommit": "f90017bc",
"baseMaster": "c081917a",
"branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)",
"executionState": "COMPLETE shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
"nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488},
"executionState": "COMPLETE \u2014 shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 \u2014 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
"nativeTaskIds": {
"1": 482,
"2": 483,
"3a": 484,
"3b": 485,
"4": 486,
"5": 487,
"6": 488
},
"tasks": [
{"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "completed", "commit": "8b4675b1", "reviewFixCommit": "a40c77de"},
{"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "completed", "commit": "21c7645d", "reviewFixCommit": "bec37848"},
{"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"},
{"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"},
{"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"},
{"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "completed", "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"},
{"id": 6, "subject": "Task 6: Live /run verification", "status": "completed", "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"}
{
"id": 1,
"subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")",
"status": "completed",
"commit": "8b4675b1",
"reviewFixCommit": "a40c77de"
},
{
"id": 2,
"subject": "Task 2: Galaxy nested gobject hierarchy",
"status": "completed",
"commit": "21c7645d",
"reviewFixCommit": "bec37848"
},
{
"id": "3a",
"subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)",
"status": "completed",
"commit": "3fcbc70c"
},
{
"id": "3b",
"subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)",
"status": "completed",
"commit": "6855be28"
},
{
"id": 4,
"subject": "Task 4: Docs + bookkeeping",
"status": "completed",
"commit": "08a65513"
},
{
"id": 5,
"subject": "Task 5: Full build + test + final integration review",
"status": "completed",
"note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"
},
{
"id": 6,
"subject": "Task 6: Live /run verification",
"status": "completed",
"note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"
}
],
"reviewFollowUps": [
"FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)",
@@ -21,5 +66,6 @@
"FOCAS: live auto-fetch on the real backend needs a cnc_getfigure command added to the managed FocasWireClient wire protocol (WireFocasClient returns empty today)",
"Galaxy minor: 3-node chain cycle provably-correct but not directly tested (2-node mutual cycle is tested)"
],
"lastUpdated": "2026-06-16"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -4,28 +4,121 @@
"designCommit": "efccd8d1",
"baseMaster": "050164b2",
"branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)",
"executionState": "COMPLETE shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
"executionState": "COMPLETE \u2014 shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 \u2014 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) \u2014 all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
"scope": "Big-bang all 5 drivers (AskUserQuestion) + full AbLegacy array read (AskUserQuestion). 1-D read-surface only; array writes / multi-dim / array historization out of scope (array nodes forced read-only at the applier per review M-1).",
"nativeTaskIds": {"1": 495, "2": 496, "3": 497, "4": 498, "5": 499, "6": 500, "7": 501, "8": 502, "9": 503, "10": 504, "11": 505, "12": 506},
"nativeTaskIds": {
"1": 495,
"2": 496,
"3": 497,
"4": 498,
"5": 499,
"6": 500,
"7": 501,
"8": 502,
"9": 503,
"10": 504,
"11": 505,
"12": 506
},
"tasks": [
{"id": 1, "subject": "Sink contract — EnsureVariable array params", "classification": "high-risk", "status": "completed", "commit": "a7928202", "reviewFixCommit": "3172b7bd"},
{"id": 2, "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", "classification": "high-risk", "status": "completed", "commit": "71cc4171", "reviewFixCommit": "584e9f2a"},
{"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"},
{"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"},
{"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"},
{"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"},
{"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"},
{"id": 8, "subject": "S7 block-read array + decode loop + IsArray", "classification": "high-risk", "status": "completed", "commit": "a82c22c6", "reviewFixCommit": "3bbe39c1+d30fb77e"},
{"id": 9, "subject": "AbLegacy PCCC multi-element array read + IsArray", "classification": "high-risk", "status": "completed", "commit": "95006939", "reviewFixCommit": "ce5d46be+0f92e9e2"},
{"id": 10, "subject": "Docs + bookkeeping", "classification": "small", "status": "completed", "commit": "05c7e86f"},
{"id": 11, "subject": "Full build + test + final integration review", "classification": "standard", "status": "completed", "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"},
{"id": 12, "subject": "Live /run acceptance + finish branch", "classification": "standard", "status": "completed", "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"}
{
"id": 1,
"subject": "Sink contract \u2014 EnsureVariable array params",
"classification": "high-risk",
"status": "completed",
"commit": "a7928202",
"reviewFixCommit": "3172b7bd"
},
{
"id": 2,
"subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier",
"classification": "high-risk",
"status": "completed",
"commit": "71cc4171",
"reviewFixCommit": "584e9f2a"
},
{
"id": 3,
"subject": "DeploymentArtifact decode byte-parity",
"classification": "high-risk",
"status": "completed",
"commit": "0a747c34",
"reviewFixCommit": "eb8a8dc1"
},
{
"id": 4,
"subject": "AdminUI driver-agnostic isArray/arrayLength control",
"classification": "standard",
"status": "completed",
"commit": "c2006dfb"
},
{
"id": 5,
"subject": "Modbus String/BitInRegister array decode + resolver",
"classification": "small",
"status": "completed",
"commit": "8d3dc321",
"reviewFixCommit": "49ac1392"
},
{
"id": 6,
"subject": "AbCip libplctag array read + IsArray",
"classification": "standard",
"status": "completed",
"commit": "f4d5a5ee",
"reviewFixCommit": "94e8c55b+5f7a2acd"
},
{
"id": 7,
"subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)",
"classification": "standard",
"status": "completed",
"commit": "3e742395"
},
{
"id": 8,
"subject": "S7 block-read array + decode loop + IsArray",
"classification": "high-risk",
"status": "completed",
"commit": "a82c22c6",
"reviewFixCommit": "3bbe39c1+d30fb77e"
},
{
"id": 9,
"subject": "AbLegacy PCCC multi-element array read + IsArray",
"classification": "high-risk",
"status": "completed",
"commit": "95006939",
"reviewFixCommit": "ce5d46be+0f92e9e2"
},
{
"id": 10,
"subject": "Docs + bookkeeping",
"classification": "small",
"status": "completed",
"commit": "05c7e86f"
},
{
"id": 11,
"subject": "Full build + test + final integration review",
"classification": "standard",
"status": "completed",
"note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"
},
{
"id": 12,
"subject": "Live /run acceptance + finish branch",
"classification": "standard",
"status": "completed",
"note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"
}
],
"reviewFollowUps": [
"Foundation array test asserts in-process variable.Value only add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements add array element formatting",
"Foundation array test asserts in-process variable.Value only \u2014 add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements \u2014 add array element formatting",
"Array WRITES (inbound client->device), multi-dimensional arrays, array historization remain out of scope (named deferrals)",
"Live array read for S7/AbCip/TwinCAT/AbLegacy is fixture-gated/operator-gated (unit-proven only); the 5 AbLegacy libplctag PCCC array-read assumptions need a real SLC/MicroLogix to confirm"
],
"lastUpdated": "2026-06-16"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -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.**
@@ -1,27 +1,185 @@
{
"planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md",
"tasks": [
{ "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] },
{ "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] },
{ "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] },
{ "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] },
{ "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] },
{ "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] },
{ "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] },
{ "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] },
{ "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] },
{ "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] },
{ "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] },
{ "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] },
{ "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] },
{ "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] },
{ "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] },
{ "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] },
{ "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] },
{ "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] },
{ "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] },
{ "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] },
{ "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] }
{
"id": 0,
"subject": "Task 1: Consume gateway packages + scaffold Gateway driver project",
"status": "completed",
"blockedBy": []
},
{
"id": 1,
"subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 3,
"subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 4,
"subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 5,
"subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 6,
"subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)",
"status": "completed",
"blockedBy": [
1,
3
]
},
{
"id": 7,
"subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "Task 9: Reshape ServerHistorianOptions to gateway form",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 9,
"subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)",
"status": "completed",
"blockedBy": [
6,
8
]
},
{
"id": 10,
"subject": "Task 11: ReadEventsAsync alarm-history on the data source",
"status": "completed",
"blockedBy": [
6,
4
]
},
{
"id": 11,
"subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)",
"status": "completed",
"blockedBy": [
9,
5
]
},
{
"id": 12,
"subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs",
"status": "completed",
"blockedBy": [
11
]
},
{
"id": 13,
"subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)",
"status": "completed",
"blockedBy": [
9,
2
]
},
{
"id": 14,
"subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()",
"status": "completed",
"blockedBy": [
13
]
},
{
"id": 15,
"subject": "Task 16: FasterLog historization outbox store",
"status": "completed",
"blockedBy": [
9
]
},
{
"id": 16,
"subject": "Task 17: ContinuousHistorizationRecorder actor",
"status": "completed",
"blockedBy": [
15,
9
]
},
{
"id": 17,
"subject": "Task 18: Wire recorder into DI + hosted lifecycle",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 18,
"subject": "Task 19: Retire Wonderware historian projects",
"status": "completed",
"blockedBy": [
9,
12,
17,
19
]
},
{
"id": 19,
"subject": "Task 20: Env-gated live validation vs wonder-sql-vd03",
"status": "completed",
"blockedBy": [
9,
10,
12,
17
]
},
{
"id": 20,
"subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)",
"status": "completed",
"blockedBy": [
18
]
}
],
"lastUpdated": "2026-06-26"
"lastUpdated": "2026-07-27",
"closureNote": "Shipped as PR #423 and live-validated against the gateway on wonder-sql-vd03. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -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
@@ -2,14 +2,69 @@
"planPath": "docs/plans/2026-07-22-per-cluster-mesh-program.md",
"note": "PROGRAM plan: each task = write that phase's detailed plan (writing-plans), execute it (executing-plans), run its exit gate, update the tracking tables. Prereq task 0 is the separate selfform-fallback plan in this repo.",
"tasks": [
{"id": 0, "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", "status": "completed", "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."},
{"id": 1, "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", "status": "completed", "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."},
{"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]},
{"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", "status": "pending", "blockedBy": [2]},
{"id": 6, "subject": "Phase 6: mesh partition — per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", "status": "pending", "blockedBy": [0, 4, 5]},
{"id": 7, "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", "status": "pending", "blockedBy": [6]}
{
"id": 0,
"subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)",
"status": "completed",
"note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."
},
{
"id": 1,
"subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB",
"status": "completed",
"note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."
},
{
"id": 2,
"subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 4,
"subject": "Phase 4: cut driver-side ConfigDb \u2014 EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 6,
"subject": "Phase 6: mesh partition \u2014 per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope",
"status": "completed",
"blockedBy": [
0,
4,
5
]
},
{
"id": 7,
"subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook",
"status": "completed",
"blockedBy": [
6
]
}
],
"lastUpdated": "2026-07-22T00:00:00Z"
"lastUpdated": "2026-07-27",
"closureNote": "Program COMPLETE (Phases 0a-7 + bootstrap guard), origin/master d1dac87f. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -100,7 +100,7 @@
{
"id": 9,
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
"status": "deferred",
"status": "completed",
"blockedBy": [
6
],
@@ -128,5 +128,6 @@
"result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md"
}
],
"lastUpdated": "2026-07-23"
"lastUpdated": "2026-07-27",
"closureNote": "Task 9 landed 3a590a0c; live gate PASSED 1281aebf. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -8,7 +8,7 @@
> **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.
> Last updated: 2026-07-27.
## Legend
@@ -30,10 +30,10 @@ it reaches 📝.
| 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) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **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 |
| **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) | **Done** — merged to master `0f38f486` (#495), 11 tasks, live gate PASSED | **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) | **Done** — merged to master `4ad54037`, 22 tasks, live gate PASSED; follow-ups #496/#497/#498 closed in `28c28667` | 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) | **COMPLETE** — P1 Agent MVP, all 23 tasks + the 5-leg live gate PASSED (branch `feat/mtconnect-driver`, PR #506) | SM | Dockerized `mtconnect/agent:2.7.0.12` (not `cppagent`) — CI-simulatable, live at `10.100.0.35:5000` |
| **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) | **COMPLETE** — P1 + P2 shipped, both live-gated (Tasks 026) | ML | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, live at `10.100.0.35:8883` |
> 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
@@ -49,22 +49,28 @@ running the classification-driven review chain (`trivial` = implement only … `
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:**
> ⚠️ **All four plans below have been executed and merged.** This table is retained as the *pattern*
> for executing a future driver plan — do **not** run these four commands, they would rebuild shipped
> drivers in a fresh worktree. (Their `.tasks.json` files still read `pending`; that is stale
> bookkeeping, not backlog — see `deferment.md` §6.1.)
| 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` |
| Deliverable | Status | Command (pattern only — do not re-run) |
|---|---|---|
| Modbus RTU (Wave 1) | ✅ merged `0f38f486` | ~~`… execute docs/plans/2026-07-24-modbus-rtu-driver.md …`~~ |
| SQL poll (Wave 1) | ✅ merged `4ad54037` | ~~`… execute docs/plans/2026-07-24-sql-poll-driver.md …`~~ |
| MTConnect (Wave 2) | ✅ merged `90bdaa44` (#506) | ~~`… execute docs/plans/2026-07-24-mtconnect-driver.md …`~~ |
| MQTT/Sparkplug (Wave 2) | ✅ merged `c3a2b0f7` | ~~`… execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md …`~~ |
For a **new** driver the invocation shape is:
`Use superpowers-extended-cc:subagent-driven-development to execute <plan-path> 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.
- **Order (historical):** the four were built lowest-effort-first — Modbus RTU → SQL poll → MTConnect
→ MQTT/Sparkplug. All are merged; nothing in this list remains to schedule. Run one plan per
worktree; independent plans may run concurrently in separate worktrees.
---
@@ -87,12 +93,12 @@ marginal cost.
---
## Wave 1 — low-effort / high-leverage pair 📝
## 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.
SQL poll reuses the always-on central SQL Server. **Both shipped.**
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
### Modbus RTU — ✅ Done (merged `0f38f486` / #495, live gate PASSED)
- **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), all complete via subagent-driven-development.
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
@@ -117,11 +123,14 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
**5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
### SQL poll — 📝 Plan ready
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
- **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`).
- **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, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
persisted `connectionString` (`1919a8e5`).
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
shipped; Postgres/ODBC deferred 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
@@ -130,41 +139,123 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
---
## Wave 2 — strategic telemetry / UNS pair 📝
## Wave 2 — strategic telemetry / UNS pair
Both CI-simulatable on the shared docker host, no hardware.
### MTConnect Agent — 📝 Plan ready
### MTConnect Agent — ✅ Done (P1 Agent MVP)
- **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).
- **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; 22 built, Task 21 (live `/run` gate) tracked separately in the plan file.**
- **Driver guide:** [`docs/drivers/MTConnect.md`](../drivers/MTConnect.md) — config keys, capability
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
limitations** section.
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
(both the sequence-gap and `OUT_OF_RANGE` legs).
- **Diverged from the design:** Task 0/6/7 dropped the planned TrakHound `MTConnect.NET-Common`/`-HTTP`
dependency entirely — the pinned packages ship no XML formatter and the HTTP stream client has no
injectable handler to test behind the driver's seam. Parsing is hand-rolled `System.Xml.Linq`,
matching on `LocalName` only (namespace-agnostic, so 1.32.x all parse). See the driver guide's
"Built vs. planned" section.
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
drivers besides Galaxy), and no driver form blocks Save on a required-field validation error.
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/agent` +
stdlib-SHDR-adapter integration fixture (env-gated), deployed at `/opt/otopcua-mtconnect/` on the
shared docker host. See the fixture's own
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
- **Effort:** SM, hand-rolled (the TrakHound estimate in the design doc did not apply once that
path was ruled out).
### MQTT / Sparkplug B — 📝 Plan ready
### MQTT / Sparkplug B — **COMPLETE** (P1 + P2, both live-gated)
- **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→
- **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, all done** (P1 plain MQTT = Tasks 014; P2 Sparkplug B = Tasks 1526).
- **Scope delivered:** 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).
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883`
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
`tests/Drivers/…​.Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
(`MQTT_FIXTURE_ENDPOINT`, see `infra/README.md` §3). The **project-owned C# Sparkplug edge-node
simulator** (`--profile sparkplug`, group `OtOpcUaSim`, nodes `EdgeA`/`EdgeB`, `EdgeA` device
`Filler1`) shipped with P2 and answers rebirth NCMDs.
- **P2 status (2026-07-25):** Tasks 1526 complete. **Offline 1528 passed / 0 failed**
(581 driver · 809 AdminUI · 138 Core.Abstractions); **live 15/15** against the broker + simulator.
- **P1 live gate found two AdminUI authoring gaps** (the gate's whole point — both invisible to green
unit tests, because AdminUI has no bUnit and nothing tied these hand-maintained lists to
`DriverTypeNames`):
1. **FIXED —** `RawDriverTypeDialog`'s option array never got an MQTT row, so **no operator could
create an MQTT driver at all**. Fixed, plus a reflection parity guard
(`RawDriverTypeDialogParityTests`) that fails for *any* future `DriverTypeNames` entry
missing from the picker.
2. **FIXED —** `MqttDriverForm` / `MqttDeviceForm` did not exist, so the broker connection was
unauthorable from the AdminUI. Both shipped after the P1 gate.
- **P2 live gate found a third one, of the same family — FIXED:** `MqttDriverForm` still carried its
P1 Sparkplug **placeholder**. Switching Mode to `SparkplugB` rendered a *"not available yet"* notice
and **no Group ID field** — so once Sparkplug ingest shipped there was still **no way to author a
Sparkplug driver from the AdminUI**, and `Sparkplug.GroupId` is the driver's entire subscription
filter (`spBv1.0/{GroupId}/#`): blank ⇒ connected, `Healthy`, ingesting nothing. Fixed (all five
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
hand-maintained AdminUI surface left behind by a driver-side feature.**
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
previously rendered exactly once at open and never again).
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
any browse-tree node label navigated the whole AdminUI to `/`**, tore down the circuit and destroyed
the hosting modal — losing the browse session and the tag selection. `@onclick:preventDefault` does
not help: it suppresses the browser's default action, not Blazor's own interceptor. The labels are
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
factory routes on the Sparkplug tuple and ignores it.
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
- **Redundant-pair hazard (documented, not a code change):** both nodes of a pair run the driver, so a
**fixed `clientId` makes them evict each other forever** — the broker logs
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
reporting `Healthy`. Unset (the default) is correct. `MqttDriverForm` warns inline the moment a
value is typed.
- **Effort:** ML, delivered. The MQTTnet-5/net10 + central-pinning risk is **retired** (Task 0's
spike held through both phases); Sparkplug state-machine correctness was carried by golden-payload
vectors + the live simulator.
---
## 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.
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for BACnet (MTConnect
shipped its own Task-21 live `/run` gate independently — see the plan file).
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
Update this file's Summary table and per-wave status whenever a deliverable changes state.
@@ -1,17 +1,91 @@
{
"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]}
{
"id": 0,
"subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)",
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)",
"status": "completed"
},
{
"id": 4,
"subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)",
"status": "completed",
"blockedBy": [
2,
3
]
},
{
"id": 5,
"subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode",
"status": "completed",
"blockedBy": [
0,
4
]
},
{
"id": 6,
"subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 7,
"subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory",
"status": "completed",
"blockedBy": [
5,
6
]
},
{
"id": 8,
"subject": "Task 8: AdminUI Transport selector on ModbusDriverForm",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 9,
"subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test",
"status": "completed",
"blockedBy": [
4,
7
]
},
{
"id": 10,
"subject": "Task 10: Live /run verification on docker-dev",
"status": "completed",
"blockedBy": [
8,
9
]
}
],
"lastUpdated": "2026-07-24"
"lastUpdated": "2026-07-27",
"closureNote": "Merged 0f38f486 (#495); live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -1,34 +1,315 @@
{
"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).",
"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 \u2014 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]}
{
"id": 0,
"subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning",
"status": "completed",
"classification": "standard",
"parallelizableWith": []
},
{
"id": 1,
"subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)",
"status": "completed",
"classification": "small",
"parallelizableWith": [],
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
1
]
},
{
"id": 4,
"subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)",
"status": "completed",
"classification": "high-risk",
"parallelizableWith": [],
"blockedBy": [
3
]
},
{
"id": 5,
"subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)",
"status": "completed",
"classification": "small",
"parallelizableWith": [
6
],
"blockedBy": [
3
]
},
{
"id": 6,
"subject": "Task 6 (P1): ISubscribable plain topic subscribe\u2192OnDataChange + retained seed",
"status": "completed",
"classification": "standard",
"parallelizableWith": [
5
],
"blockedBy": [
2,
4
]
},
{
"id": 7,
"subject": "Task 7 (P1): MqttDriver shell \u2014 IDriver + authored-only ITagDiscovery (Once) + probe interface",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
5,
6
]
},
{
"id": 8,
"subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake",
"status": "completed",
"classification": "small",
"parallelizableWith": [
10
],
"blockedBy": [
3
]
},
{
"id": 9,
"subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
7,
8
]
},
{
"id": 10,
"subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)",
"status": "completed",
"classification": "standard",
"parallelizableWith": [
8
],
"blockedBy": [
2
]
},
{
"id": 11,
"subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)",
"status": "completed",
"classification": "trivial",
"parallelizableWith": [
12
],
"blockedBy": [
10
]
},
{
"id": 12,
"subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)",
"status": "completed",
"classification": "standard",
"parallelizableWith": [
11
],
"blockedBy": [
2
]
},
{
"id": 13,
"subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
9
]
},
{
"id": 14,
"subject": "Task 14 (P1): Live /run verify on docker-dev \u2014 P1 MILESTONE COMPLETE",
"status": "completed",
"classification": "small",
"parallelizableWith": [],
"blockedBy": [
9,
11,
12,
13
]
},
{
"id": 15,
"subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
14
]
},
{
"id": 16,
"subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors",
"status": "completed",
"classification": "standard",
"parallelizableWith": [
17
],
"blockedBy": [
15
]
},
{
"id": 17,
"subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map",
"status": "completed",
"classification": "small",
"parallelizableWith": [
16
],
"blockedBy": [
15
]
},
{
"id": 18,
"subject": "Task 18 (P2): BirthCache + AliasTable \u2014 bind-by-name, rebuild-per-birth",
"status": "completed",
"classification": "high-risk",
"parallelizableWith": [
19
],
"blockedBy": [
16,
17
]
},
{
"id": 19,
"subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie",
"status": "completed",
"classification": "high-risk",
"parallelizableWith": [
18
],
"blockedBy": [
16,
17
]
},
{
"id": 20,
"subject": "Task 20 (P2): RebirthRequester NCMD encode",
"status": "completed",
"classification": "small",
"parallelizableWith": [],
"blockedBy": [
16
]
},
{
"id": 21,
"subject": "Task 21 (P2): Sparkplug ingest state machine \u2014 the 3.6 matrix",
"status": "completed",
"classification": "high-risk",
"parallelizableWith": [],
"blockedBy": [
18,
19,
20
]
},
{
"id": 22,
"subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
21
]
},
{
"id": 23,
"subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync",
"status": "completed",
"classification": "standard",
"parallelizableWith": [
24
],
"blockedBy": [
20,
21
]
},
{
"id": 24,
"subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator",
"status": "completed",
"classification": "small",
"parallelizableWith": [
23
],
"blockedBy": [
12,
17
]
},
{
"id": 25,
"subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix",
"status": "completed",
"classification": "standard",
"parallelizableWith": [],
"blockedBy": [
21,
22,
23
]
},
{
"id": 26,
"subject": "Task 26 (P2): Live /run verify Sparkplug \u2014 P2 COMPLETE",
"status": "completed",
"classification": "small",
"parallelizableWith": [],
"blockedBy": [
23,
24,
25
]
}
],
"lastUpdated": "2026-07-24"
"lastUpdated": "2026-07-27",
"closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
+317
View File
@@ -48,6 +48,102 @@ git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
```
**DECISION (verified 2026-07-24): TrakHound path.** All three checks passed against real nuget.org.
(1) `dotnet package search MTConnect.NET-Common --exact-match` and `...-HTTP --exact-match` both list
versions `3.2.0` through `6.9.0.2` on `nuget.org`, confirming `6.9.0.2` is the current top-of-list
version for both package ids (no version mismatch between the two). (2) A throwaway `net10.0` console
project in the scratchpad (outside the repo, so unaffected by this repo's `packageSourceMapping`) ran
`dotnet add package MTConnect.NET-Common -v 6.9.0.2` + `...-HTTP -v 6.9.0.2` + `dotnet restore` — both
restored cleanly with no source-mapping or compatibility errors; `project.assets.json` resolved
`MTConnect.NET-Common`, `MTConnect.NET-HTTP`, and the transitive `MTConnect.NET-TLS` (all `6.9.0.2`)
against the `net10.0` target framework, and each package's `lib/` folder contains a `netstandard2.0`
build (alongside `net6.0``net9.0`, `net461``net472`, `net47`, `net48`), so net10.0 resolves via the
in-box compatibility mapping. (3) The pinned version carries **no embedded LICENSE file** in the
`.nupkg` (`unzip -l` shows no `LICENSE*` entry in either package) — instead both nuspecs declare the
modern SPDX license-expression form `<license type="expression">MIT</license>` +
`<licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>`, which nuget.org validates against the SPDX
list at push time and surfaces on the package page (confirmed live: the nuget.org page for
`MTConnect.NET-Common` `6.9.0.2` displays "MIT license" linked to `licenses.nuget.org/MIT`) — a
structurally stronger guarantee than a free-text embedded file for this specific version, so the
absence of a physical `LICENSE` file is not a fail. Task 1 adds
`PackageReference MTConnect.NET-Common 6.9.0.2` + `PackageReference MTConnect.NET-HTTP 6.9.0.2` to
`Driver.MTConnect.csproj`; the hand-roll fallback is not needed.
**CORRECTION (Task 6, commit `bdfefadc`) — the decision above rested on incomplete evidence, and the
hand-roll fallback IS in use for parsing.** Task 0 verified the packages *exist, restore, and are MIT*;
it never verified they can actually parse an MTConnect document. As pinned, they cannot. Task 6 proved
this empirically before writing any parser code: reflection over both referenced assemblies
(`MTConnect.NET-Common`, 1297 types; `-HTTP`, 410 types) found **zero `IResponseDocumentFormatter`
implementations and zero public static `FromXml`/parse entry points**, and a live invocation of
TrakHound's own entry point (`ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)`)
against this repo's `Fixtures/probe.xml`, with both DLLs loaded, returned:
```
Success = False
ERROR: Document Formatter Not found for "xml"
```
TrakHound 6.x resolves formatters from loaded assemblies at runtime, and the XML formatter ships in a
**third package, `MTConnect.NET-XML`**, which Task 0 did not evaluate. Even with it added, TrakHound
exposes no socket-free `Parse(string)` entry point of the shape this plan mandates. So `/probe` parsing
is a hand-rolled `System.Xml.Linq` walk (~250 LoC, `MTConnectProbeParser`); the driver above the
`IMTConnectAgentClient` seam is unaffected, exactly as the decision rule anticipated.
**The two `PackageReference`s remain and are, so far, dead weight.** Task 6 deliberately did not remove
them: the hard remaining problem is Task 7's `multipart/x-mixed-replace` chunk framing for `/sample`,
and `MTConnect.Clients.MTConnectHttpClientStream` in `-HTTP` may be usable for framing alone. **Task 7
owns the final call:** (a) use `-HTTP` for framing only, (b) add `MTConnect.NET-XML` and reconsider
wholesale, or (c) hand-roll the boundary reader and **drop both package references** — in which case the
Task-0 rationale comment in `Directory.Packages.props` goes with them. Record the outcome here.
**OUTCOME (Task 7) — option (c): hand-rolled boundary reader, BOTH package references DROPPED.**
`MTConnect.NET-Common`, `MTConnect.NET-HTTP` and the transitive `MTConnect.NET-TLS` are gone from
`Directory.Packages.props` and from `Driver.MTConnect.csproj`; the Task-0 rationale comments went with
them (each replaced by a comment naming this block). **The driver now depends on nothing but the BCL**
`HttpClient` + `System.Xml.Linq`. Reflection over `MTConnect.NET-HTTP` 6.9.0.2 (net9.0 lib, with
`-Common` resolved alongside) settled the one open candidate:
```
TYPE MTConnect.Clients.MTConnectHttpClientStream
.ctor(String url, String documentFormat)
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
```
Three independent disqualifiers, any one of which is fatal:
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 613 are unit-testable
against canned XML with no listener; adopting this type would have forced a live agent (or a real
loopback HTTP server) into the unit suite.
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
watchdog on its body.
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
a second: **check the constructor for a test seam before counting a library as usable** — a type that
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
---
## Task 1: Scaffold the two driver projects + the test project
@@ -289,6 +385,57 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "Ful
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
```
### Task 7 review remediation (post-`c46540ae`) — three contract changes Tasks 9/11/13 must build against
Code review of `c46540ae` approved the framing algorithm but moved the risk onto the **contract**. Three
changes here are breaking relative to the sketch above; the rest are hardening.
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
yielding "indefinitely, until `ct` is cancelled", but the implementation returned normally on EOF, on
the closing `--boundary--`, and — worst — on a response that was not multipart at all, where it
yielded one parsed snapshot and finished. A pump written to the documented contract has no reason to
handle normal completion, so it would silently drop its subscription or hot-loop reconnecting; the
non-multipart leg additionally reported a **configuration error as a healthy finished stream**, which
is the #485 quiet-successful-termination shape aimed straight at Task 11. Every non-cancellation end
now throws: `MTConnectStreamEndedException` (with `MTConnectStreamEndReason.ConnectionClosed` /
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
**not** yielded.
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
alone does not cover it. Documented on the seam.
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
nonsense (`"12"` for two entries) which the index would otherwise publish as Good, and only the
parser can tell that apart from a legitimate space-bearing `Message` — no value-shape heuristic
works. Deliberately **false for CONDITION** observations: their value comes from the element *name*,
so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
Hardening in the same pass: disposal mid-enumeration now surfaces as `OperationCanceledException`
(via an internal dispose-linked token) rather than a raw `ObjectDisposedException`/`IOException` that
Task 9's re-init would otherwise inflict on an enumerating pump; `HeartbeatMs`, `SampleIntervalMs` and
`SampleCount` join `RequestTimeoutMs` in construction-time positive-value validation (all three reach
the query string, and an Agent answers `heartbeat=0` with an HTTP-200 `MTConnectError` that would name
no config key); a `multipart/*` response with no `boundary` parameter fails fast instead of degrading
into a watchdog timeout; the no-`Content-length` framing fallback logs a one-shot warning (the client
now takes an optional `ILogger`); and the shared element/attribute reading rules moved into
`MTConnectXml`, used by both parsers.
**Coverage gap closed, and worth remembering as a pattern.** Every multipart test served the whole body
from one buffer, so **every framing test completed in a single `ReadAsync`** — the split-boundary path,
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
headline risk is framing. A `ChunkedStream` double returning N bytes per read (N = 1, 3, 7) now drives
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
split-boundary overlap, removing the split-header overlap, and collapsing `EnsureAsync`'s fill loop each
fail **only** the new chunked tests — every pre-existing multipart test stays green under all three.
---
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
@@ -747,6 +894,176 @@ git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/sub
---
### LIVE-GATE RESULT (2026-07-24, completed 2026-07-27) — ALL 5 LEGS PASSED
**Rig.** The shared `otopcua-dev` rig was NOT used: three sibling driver worktrees
(`modbus-rtu`, `mqtt-sparkplug`, `sql-poll-driver`) share its `otopcua-host:dev` image, and it had
been up for hours. Instead an isolated stack was built from this worktree —
`otopcua-host:mtconnect`, compose project `otopcua-mtc`, overlay
`docker-dev/docker-compose.mtconnect.yml`, MAIN pair only, ports AdminUI **9220** / OPC UA **4860**
/ SQL **14340**. Mirrors how the mqtt worktree isolated itself. The `otopcua-dev` rig and every
sibling worktree were left untouched.
**Fixture.** `mtconnect/agent:2.7.0.12` + the SHDR adapter deployed to
`/opt/otopcua-mtconnect/` on the shared docker host and brought up healthy; `http://10.100.0.35:5000`
serves the seeded `OtFixtureCnc` model with live-moving values. Note it answers in the MTConnect
**2.0** namespace while the canned fixtures are 1.3 — the namespace-agnostic `LocalName` parsing
(Task 6) is what makes both work, now demonstrated rather than argued.
| Leg | Result | Evidence |
|---|---|---|
| Integration suite vs. a REAL Agent | ✅ **12/12** | incl. `Discovery_types_an_upper_snake_numeric_event_as_Int64` (the `PART_COUNT` regression), `Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags` (the blocker fix), `Subscribe_delivers_a_changed_value_from_the_live_sample_stream`, `Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs` |
| 1. Driver creatable in `/raw` | ✅ | `MTConnect` present in `RawDriverTypeDialog`; `mtc-1` created. Proves the Host `Register` call is genuinely wired — **the one thing no test guards** (deleting it leaves the guard test green and substitutes a `StubbedDriver` while the deploy still seals green). |
| 2. Typed config modal renders | ✅ | "Configure driver · MTConnect" with all four sections (Agent / Polling / Connectivity probe / Reconnect), **not** the "no typed config form" banner. **This is the mutation that survived all 749 AdminUI tests** — the `switch` in `DriverConfigModal.razor` compiles to `BuildRenderTree` and is unreachable by reflection. |
| 3. Config persists + round-trips | ✅ | Stored `{"agentUri":"http://10.100.0.35:5000","requestTimeoutMs":5000,…,"probe":{"enabled":true,…},"reconnect":{…}}`; reopening the modal displays the saved URI (Razor binding verified — no bUnit in this repo). |
| 4. Browse picker → commit | ✅ | "Connect & browse" dialled the live Agent; tree rendered `Agent` + `OtFixtureCnc` with `Favail` (name ≠ id) beside id-named leaves, proving `browseName = Name ?? Id`. Committed TagConfig is **`{"fullName":"fixture_asset_changed","dataType":"String"}`** — the `fullName`-not-`address` fix, live. Wave-0 #468 did **not** block this. |
| 5. Deploy → OPC UA read/subscribe | ✅ **PASSED (2026-07-27, post-rebase)** | see "Leg 5" below |
---
#### Leg 5 — PASSED 2026-07-27, on the rebased branch
Re-run after the rebase onto master (`123ddc3f`), against a **freshly rebuilt** `otopcua-host:mtconnect`
image, so this gate exercises the merged code and not the pre-rebase tree.
**The cookie blocker was sidestepped, not worked around.** Rather than moving hosts or stopping the
`otopcua-dev` rig, the deploy went through the **headless deploy API**
`POST /api/deployments` with `X-Api-Key` (`DeployApiEndpoints`, `Security:DeployApiKey`). It is
`AllowAnonymous().DisableAntiforgery()` by design ("machine endpoint, not a browser form post"), so
the antiforgery cookie collision is structurally irrelevant to it. This is the better recipe for any
future isolated-stack gate — it needs no browser at all:
```bash
curl -s -X POST http://localhost:9220/api/deployments \
-H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" \
-d '{"CreatedBy":"mtconnect-live-gate"}'
# {"outcome":"Accepted","deploymentId":"…","revisionHash":"…"} HTTP 202
```
Both deployments sealed `Status = 2` (`DeploymentStatus.Sealed`) with `FailureReason = NULL` — i.e.
**both** MAIN nodes acked. No `MaintenanceMode` hatch was needed: the isolated stack's topology is
MAIN-only with both central nodes enabled and running.
**Tag set.** Leg 4's AdminUI-committed tag (`fixture_asset_changed`) is an `AssetChanged` EVENT that
never moves, so three live-changing tags were added **directly via SQL** (deliberately bypassing the
typed editor — see the finding below) to exercise all three type paths.
**Results — read (`Client.CLI read`, `opc.tcp://localhost:4860`):**
| NodeId (`ns=2`, Raw realm) | Value | Status |
|---|---|---|
| `s=mtc-1/vmc/fixture_partcount` | `48099` (Int64) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_x_pos` | `96.4416` (Float64) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_execution` | `ACTIVE` (String) | `0x00000000` Good |
| `s=mtc-1/vmc/fixture_asset_changed` | *(null)* | `0x80000000` — see finding 6 |
The RawPath is `mtc-1/vmc/<dataItemId>` (driver/device/tag; no folder prefix), confirmed by
`browse -r`, which rendered all four as `[Variable]` under the `vmc` device object.
**Results — subscribe (`Client.CLI subscribe -r` over the whole device, 500 ms):** all four monitored;
live updates flowed continuously from the `/sample` long-poll pump through to OPC UA — `fixture_x_pos`
tracing its sinusoid (`103.66 → 123.18 → 141.90 → 155.27 → 159.99 → 154.93 → 141.32 → 122.48 → 103.03
→ 87.75 → 80.35`), `fixture_partcount` incrementing monotonically (`48128 → 48129 → 48130`), and
`fixture_execution` transitioning `READY → INTERRUPTED`. **This closes the last unproven link**: the
driver seam was already covered by the live integration suite, and leg 5 adds `DeploymentArtifact`
address-space materialisation → OPC UA publish on top of it.
**Additional findings from leg 5:**
5. **A bad `dataType` degrades to exactly one skipped tag, loudly — verified by accident.** The
hand-written SQL used `"dataType":"Double"`; the vocabulary is `DriverDataType`, whose member is
**`Float64`**. The driver logged, per tag:
> `could not map the raw tag mtc-1/vmc/fixture_x_pos to an Agent DataItem; it names no readable id
> (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The
> tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.`
…and the other three tags kept streaming. That is the intended fail-isolated behaviour,
demonstrated live rather than argued. Correcting the row to `Float64` and redeploying turned the
tag Good with **no container restart** — which incidentally shows **MTConnect is not subject to the
separately-tracked config-edits-silently-discarded defect** that affects Modbus/FOCAS/OpcUaClient.
Note also that the typed AdminUI editor would have prevented the mistake outright (it offers a
`DriverDataType` dropdown); the error was an artifact of authoring straight into SQL.
6. **`fixture_asset_changed` reading `0x80000000` is NOT an MTConnect defect — it is a pre-existing,
deliberate fidelity gap on the shared publish path.** The agent does return the tag in `/current`
as `<AssetChanged …>UNAVAILABLE</AssetChanged>`, and the driver maps it correctly to
`BadNoCommunication` (`0x80310000`, `MTConnectObservationIndex.cs:255`). The node nevertheless
reports generic `Bad` (`0x80000000`) — while carrying the agent's exact source timestamp, which
proves the publish landed and only the status differs. Cause, verified by reading the source:
`DriverInstanceActor.QualityFromStatus` (`DriverInstanceActor.cs:1032-1041`) projects the driver's
`uint` onto the 3-state `OpcUaQuality` enum using only the top two severity bits (`statusCode >> 30`),
and `OtOpcUaNodeManager.StatusFromQuality` (`OtOpcUaNodeManager.cs:3318-3322`) re-expands `Bad` to
`StatusCodes.Bad`. It is intentional — `OpcUaQuality`'s own doc comment says *"Real SDK has
finer-grained codes; the engine actors only need this 3-state classification."*
The consequence appears undocumented and is client-visible: **no driver's Bad/Uncertain sub-code
ever reaches an OPC UA client.** Issue #497's 16 corrected constants and the new
`StatusCodeParityTests` guard keep the constants internally right, but a client cannot tell
`BadNoCommunication` from `BadTypeMismatch` from `BadNotSupported`. (The one survivor is
`BadWaitingForInitialData`, written directly at the node at `OtOpcUaNodeManager.cs:1806/1901`,
bypassing the projection.) Tracked separately; out of scope for this plan.
---
**Historical — why leg 5 was blocked on 2026-07-24 (superseded by the API-key recipe above).**
Browser cookies are scoped to a
**host, not a port**, so the AdminUI antiforgery cookie issued by the still-running `otopcua-dev`
rig on `localhost:9200` is sent to the isolated stack on `localhost:9220`, whose data-protection
keys differ:
```
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.
```
The `Deploy current configuration` POST is rejected before reaching any MTConnect code — no
`Deployment` row is created, and the UI surfaces nothing. Clearing JS-visible cookies does not help
(the antiforgery cookie is `HttpOnly`). This is a two-stacks-on-one-host collision, entirely outside
the driver.
The three browser-side remedies originally listed (distinct host / incognito profile / stop the
`otopcua-dev` rig) all remain valid, but none was needed — the headless deploy API above avoids the
browser entirely and is the recommended recipe.
**Incidental findings from the rig work:**
1. ~~`ClusterNode` has no `MaintenanceMode` column, so CLAUDE.md is ahead of the migration.~~
**CORRECTED — this was my error, not a repo inconsistency.** The migration
`20260722125506_AddClusterNodeMaintenanceMode` exists on master, along with `AkkaPort`/`GrpcPort`.
**This branch is 39 commits behind master** (base `963eec1b`, master `28c28667`) and simply
predates them, so the isolated stack's schema lacked the column and I reduced the seeded topology
by deleting the SITE-A/SITE-B rows instead. CLAUDE.md is accurate. **DONE 2026-07-27 — rebased**
onto master `123ddc3f` (67 commits by then, not 39). Conflicts were all of one shape — this driver
and the newly-merged SQL-poll driver each adding their entry to the same list — resolved by keeping
**both** in: `ZB.MOM.WW.OtOpcUa.slnx`, `DriverTypeNames.cs`, `DriverFactoryBootstrap.cs`,
`Core.Abstractions.Tests.csproj`, `TagConfigEditorMap.cs`, `TagConfigValidator.cs`,
`DriverConfigModal.razor`, `RawDriverTypeDialog.razor`. Post-rebase: solution build 0 errors,
MTConnect 491/491, Core.Abstractions 256/256, AdminUI 800/800. For a future partial-topology gate,
`MaintenanceMode = 1` is now available and is the correct hatch (it is what the SQL-poll driver's
gate used), in place of the row deletion I resorted to.
2. `sqlcmd` against this schema needs `-I` (`SET QUOTED_IDENTIFIER ON`); without it every DML
statement fails with `Msg 1934` because of the filtered/computed indexes.
3. **Status-code parity is pre-verified against master's new guard.** Master gained
`StatusCodeParityTests`, which reflects over every `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in its bin for
status-shaped `const uint` fields — including `private const` on `internal` types — and checks each
against `Opc.Ua.StatusCodes`. Task 16 added the `Driver.MTConnect` ProjectReference to that same
test project, so this driver's constants are in scope. All eight were verified against the pinned
SDK assembly directly: `BadCommunicationError 0x80050000`, `BadNoCommunication 0x80310000`,
`BadNodeIdUnknown 0x80340000`, `BadNotConnected 0x808A0000`, `BadNotSupported 0x803D0000`,
`BadOutOfRange 0x803C0000`, `BadTypeMismatch 0x80740000`, `BadWaitingForInitialData 0x80320000`
**0 mismatches**. Note the guard only sees *named* constants; an inline status literal at a call
site is invisible to it, so keep hoisting them. **Confirmed after the rebase**: the guard reports
9 MTConnect constants in scope (the 8 above plus `Good`), all passing — the pre-verification held.
4. **The separately-tracked `BadTypeMismatch` defect in FOCAS/TwinCAT/AbLegacy/AbCip is already fixed
on master** (issue #497 grew it to 16 wrong constants across 6 drivers). This driver independently
chose the correct `0x80740000` and pinned it with a mutation test, so the two agree; no action.
**Teardown when done:**
```bash
docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect.yml down -v
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mtconnect && docker compose down'
```
---
## Task 22: Docs + deferred-writeback note; update the tracking doc
**Classification:** trivial
@@ -1,29 +1,199 @@
{
"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]}
{
"id": 0,
"subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)",
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: Scaffold the two driver projects + the test project",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 4,
"subject": "Task 4: Capture the canned XML fixtures",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 5,
"subject": "Task 5: IMTConnectAgentClient seam + return DTOs",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 6,
"subject": "Task 6: MTConnectAgentClient \u2014 parse /probe into the device model",
"status": "completed",
"blockedBy": [
4,
5
]
},
{
"id": 7,
"subject": "Task 7: MTConnectAgentClient \u2014 parse /current + /sample, detect the sequence gap",
"status": "completed",
"blockedBy": [
4,
5,
6
]
},
{
"id": 8,
"subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 9,
"subject": "Task 9: MTConnectDriver shell \u2014 IDriver lifecycle",
"status": "completed",
"blockedBy": [
2,
8
]
},
{
"id": 10,
"subject": "Task 10: IReadable.ReadAsync \u2014 /current, ordered snapshots",
"status": "completed",
"blockedBy": [
9
]
},
{
"id": 11,
"subject": "Task 11: ISubscribable \u2014 /sample long-poll pump + ring-buffer re-baseline",
"status": "completed",
"blockedBy": [
9,
7
]
},
{
"id": 12,
"subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true",
"status": "completed",
"blockedBy": [
9,
6
]
},
{
"id": 13,
"subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)",
"status": "completed",
"blockedBy": [
9
]
},
{
"id": 14,
"subject": "Task 14: MTConnectDriverProbe : IDriverProbe",
"status": "completed",
"blockedBy": [
2,
5
]
},
{
"id": 15,
"subject": "Task 15: MTConnectDriverFactoryExtensions",
"status": "completed",
"blockedBy": [
2,
9
]
},
{
"id": 16,
"subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity",
"status": "completed",
"blockedBy": [
14,
15
]
},
{
"id": 17,
"subject": "Task 17: AdminUI typed model MTConnectTagConfigModel",
"status": "completed",
"blockedBy": [
3,
16
]
},
{
"id": 18,
"subject": "Task 18: AdminUI editor razor + map/validator registration",
"status": "completed",
"blockedBy": [
17
]
},
{
"id": 19,
"subject": "Task 19: mtconnect/cppagent docker fixture",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 20,
"subject": "Task 20: Env-gated integration suite against cppagent",
"status": "completed",
"blockedBy": [
19
]
},
{
"id": 21,
"subject": "Task 21: Live /run verify on docker-dev \u2014 browse picker, editor, read, subscribe, deploy",
"status": "completed",
"blockedBy": [
18,
20
]
},
{
"id": 22,
"subject": "Task 22: Docs + deferred-writeback note; update the tracking doc",
"status": "completed",
"blockedBy": [
21
]
}
],
"lastUpdated": "2026-07-24"
"lastUpdated": "2026-07-27",
"closureNote": "Merged at master 90bdaa44 (#506); 5-leg live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -1,28 +1,191 @@
{
"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]}
{
"id": 0,
"subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx",
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 4,
"subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)",
"status": "completed",
"blockedBy": [
1,
3
]
},
{
"id": 5,
"subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)",
"status": "completed",
"blockedBy": [
2,
4
]
},
{
"id": 6,
"subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture",
"status": "completed",
"blockedBy": [
3,
4
]
},
{
"id": 7,
"subject": "Task 7: SqlPollReader core \u2014 grouped read, bounded deadline, ordered slice-back",
"status": "completed",
"blockedBy": [
5,
6
]
},
{
"id": 8,
"subject": "Task 8: SqlDriver shell \u2014 IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 9,
"subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard",
"status": "completed",
"blockedBy": [
8
]
},
{
"id": 10,
"subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)",
"status": "completed",
"blockedBy": [
4,
6
]
},
{
"id": 11,
"subject": "Task 11: Host registration \u2014 DriverTypeNames.Sql + factory + probe + guard test",
"status": "completed",
"blockedBy": [
9,
10
]
},
{
"id": 12,
"subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 13,
"subject": "Task 13: SqlBrowseSession \u2014 schema walk over dialect catalog",
"status": "completed",
"blockedBy": [
4,
12,
6
]
},
{
"id": 14,
"subject": "Task 14: SqlDriverBrowser \u2014 env-ref/literal transient-connection open",
"status": "completed",
"blockedBy": [
13
]
},
{
"id": 15,
"subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor",
"status": "completed",
"blockedBy": [
14
]
},
{
"id": 16,
"subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip",
"status": "completed",
"blockedBy": [
8
]
},
{
"id": 17,
"subject": "Task 17: Injection regression test (bind value / reject identifier)",
"status": "completed",
"blockedBy": [
7
]
},
{
"id": 18,
"subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 19,
"subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)",
"status": "completed",
"blockedBy": [
1,
11
]
},
{
"id": 20,
"subject": "Task 20: SqlTagConfigEditor.razor shell",
"status": "completed",
"blockedBy": [
19
]
},
{
"id": 21,
"subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)",
"status": "completed",
"blockedBy": [
11,
15,
20
]
}
],
"lastUpdated": "2026-07-24"
"lastUpdated": "2026-07-27",
"closureNote": "Merged 4ad54037 + follow-ups 28c28667. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
+115 -28
View File
@@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four:
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree.
3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization).
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`.
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
@@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types:
| Token | Handler | Notes |
|---|---|---|
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. |
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService``OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. |
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. |
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService``OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. |
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. |
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
@@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s
4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships.
5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
`Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production):
@@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-
## Data-Plane Authorization
Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?*
> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the
> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The
> evaluator is built and unit-tested but has **zero production call sites**, and
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything
> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is
> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie).
Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny.
### What is enforced today
### Hierarchy
There are exactly **three** authorization checks in the whole OPC UA server layer. All three are
coarse, **server-wide role string checks** with no per-node component.
| Operation | Gate | Requires |
|---|---|---|
| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` |
| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` |
| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` |
Both gates fail closed — a session with no identity or no matching role gets
`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity`
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are
the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see
[Role grant source](#role-grant-source-data-plane)).
**What has no authorization check at all:**
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
an Anonymous one, sees and reads the entire address space.
- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`,
`HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check.
- **CreateMonitoredItems / subscriptions / TransferSubscriptions**.
- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`.
**And what the write gate does *not* distinguish:**
- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in
`NodePermissions`, but every write — whatever the tag's security classification — is gated on the
single `WriteOperate` string.
- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role
covers all five alarm methods; the `operation` argument is passed to the router but never consulted
by the gate.
- **There is no per-node granularity.** A session that may write one tag may write **every** writable
tag on the node.
The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both
the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself
takes no realm and no node.
### Designed but not wired: `NodeAcl` + `PermissionTrie`
Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that
**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design
is sound and the remaining work is wiring plus one genuine design gap — not because it runs.
What is real about it:
- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are
validated, versioned and persisted.
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
edit shifts the artifact's `RevisionHash` and ships to every node.
- The node side **never deserializes them**`DeploymentArtifact` has no ACL reader. The bytes arrive
and are dropped.
So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour
to expect until the tracking issue below is closed.
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member,
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
no scope to evaluate. This is a design decision, not wiring.
2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the
session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list
is discarded inside `LdapOpcUaUserAuthenticator`.
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
to be derived from the artifact's UNS relations.
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
nothing in production constructs one, stamps a generation, or drives a refresh.
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
has no `OpcUaOperation` mapping, so it is grantable but unreachable.
### Hierarchy (design)
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
@@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces
`NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.
### Dispatch gate — `IPermissionEvaluator`
### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**)
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation.
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`.
Key properties:
⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today).
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator.
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny.
- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit.
Intended properties, of which only the last is true today:
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator.
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.**
- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired.
### Full model
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny).
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour.
Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`.
### Role grant source (data-plane)
@@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.:
```json
"GroupToRole": {
"ot-operators": "WriteOperate",
"ot-tuners": "WriteTune",
"ot-engineers": "WriteConfigure",
"ot-alarm-ack": "AlarmAck",
"ot-readonly": "ReadOnly"
"ot-alarm-ack": "AlarmAck"
}
```
If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes
and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who
authenticate successfully. (The same requirement gates both the scripted-alarm and the native
Galaxy-alarm Part-9 ack/confirm/shelve paths.)
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
wrong for three of them.
The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare
against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings
`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not**
`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a
`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
The two live role strings are exact and case-insensitive. In particular the alarm-ack role is
`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different
vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
---
+5
View File
@@ -1,5 +1,10 @@
# Admin UI rebuild plan (F15)
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 AdminUI.** Several named types
> (`AuthorizationPolicies`, `IdentificationFields`) no longer exist, and the v3 rebuild replaced the
> per-cluster UNS/Equipment/Tags tabs with the global `/uns` page and the `/raw` project tree. Kept
> for the record.
**Status:** UX kickoff — proposals to react to before any per-page rebuild starts.
**Last updated:** 2026-05-26 on `v2-akka-fuse`.
+1 -1
View File
@@ -103,7 +103,7 @@ Message contracts are defined; actual SDK calls are stubbed (counters only). Rea
Both have message contracts wired. Engine integration deferred:
- `HistorianAdapterActor`named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink` (F11).
- `HistorianAdapterActor`gRPC to the `ZB.MOM.WW.HistorianGateway` sidecar (the sole historian backend; the named-pipe Wonderware sidecar is retired) + `LocalDbStoreAndForwardSink` (F11; renamed from `SqliteStoreAndForwardSink` when the buffer moved into the consolidated LocalDb).
- `PeerOpcUaProbeActor` — real `opc.tcp://peer:4840` ping (F12). Current stub always returns `Ok=true`.
## DbHealthProbeActor
+18
View File
@@ -1,5 +1,23 @@
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2
> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was
> only half-built. The trie, the builder, the cache and the evaluator all exist in
> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but
> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are
> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never
> deserializes them.
>
> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see
> [`docs/security.md`](../security.md) § Data-Plane Authorization and
> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design
> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP
> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they
> are recorded in `deferment.md` §3.1.
>
> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3
> dual-namespace address space.
>
> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
>
> **Branch**: `v2`
+20
View File
@@ -1,5 +1,25 @@
# Driver Stability & Isolation — OtOpcUa v2
> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).**
> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier =
> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The
> Galaxy and FOCAS Tier-C assignments in the table below are aspirational.
>
> Consequences worth knowing:
>
> - The **Tier-C-only protections are dormant for every driver**`MemoryRecycle` gates on
> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever
> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still
> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing.
> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**:
> referenced only by its own test, with nothing registering metadata at startup.
> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches
> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern
> named below was retired in PR 7.2, and FOCAS runs in-process like everything else.
>
> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the
> tier machinery is tracked as Gitea **#522**; this document is retained as the design record.
>
> **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier.
>
> **Branch**: `v2`
+7
View File
@@ -1,5 +1,12 @@
# Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 codebase; do not treat its present-tense
> claims as current.** Many named types no longer exist. Notably `:84` reports four services
> ("Done | All four exist in `Admin/Services/`") — **none exist and there is no `Admin/Services/`
> directory**; the `Phase7*` composition types were renamed to `AddressSpace*` by `40e8a23e`; and
> Gaps 2/3/4 (no `/virtual-tags` page, no `/scripted-alarms` page, no script-log viewer) were closed
> by v3's `/uns`, `/alerts` and `/script-log` surfaces. Kept for the record.
> **Reconciliation date**: 2026-05-18
> **Based on**: `docs/v2/implementation/phase-7-scripting-and-alarming.md` (the plan) and
> `docs/v2/implementation/exit-gate-phase-7.md` (the exit-gate audit) cross-checked against
+8
View File
@@ -1,5 +1,13 @@
# Redundancy Interop Playbook (Phase 6.3 Stream F — task #150)
> ⚠️ **Historical type names (audited 2026-07-27).** The operator *procedure* below is still the
> right one, but `RedundancyPublisherHostedService` (`:22`), `RecoveryStateManager.DwellTime` (`:67`)
> and `RedundancyCoordinator` no longer exist — redundancy state is now published by the
> cluster-scoped `RedundancyStateActor` singleton with `ServiceLevelCalculator` and
> `IRedundancyRoleView`. See `docs/Redundancy.md` (which states plainly that the old types are gone)
> and the per-cluster-mesh Phase 6/7 sections of `CLAUDE.md`. The `ServerUriArray` limitation noted
> at `:100-105` is still open (SDK object-type gated).
> **Scope**: manual validation that third-party OPC UA clients + AVEVA MXAccess
> observe our non-transparent redundancy signals (ServiceLevel, ServerUriArray,
> RedundancySupport) and fail over to the Backup node when the Primary drops.
+27 -2
View File
@@ -1,5 +1,14 @@
# v2 Release Readiness
> ⚠️ **Historical (audited 2026-07-27) — a v2-era snapshot; several "Closed" rows name types that do
> not exist in `src/`.** In particular `:41` claims ACL enforcement was "Closed 2026-04-24" via
> `AuthorizationBootstrap` / `NodeScopeResolver` / `OpcUaServerService` — **all three have zero
> occurrences in `src/`, and there is no per-node ACL gate on any operation** (see `deferment.md`
> §3.1 and the banner in `docs/ReadWriteOperations.md`). `RedundancyCoordinator`,
> `RedundancyStatePublisher`, `PeerReachabilityTracker`, `GenerationRefreshHostedService`,
> `ClusterTopologyLoader` and `SealedBootstrap` are likewise gone. The **unchecked GA exit criteria**
> near the end of the file are still live — see `deferment.md` §5.
> **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client)
> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. Remaining work is manual (live-hardware validations, client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
@@ -28,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd
All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria.
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143**CLOSED** 2026-04-19, PR #94)
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143marked CLOSED 2026-04-19, PR #94)
> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this
> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`,
> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`,
> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in
> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and
> no `Call` override in the live node manager, and no HistoryRead path consults any gate.
>
> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane
> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm
> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are
> authored and deployed but never evaluated. See [`../security.md`](../security.md) §
> Data-Plane Authorization and `deferment.md` §3.1.
>
> The text below is retained as the historical record of what was *believed* closed. **Read none of it
> as current behaviour.**
**Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
Remaining Stream C surfaces (hardening, not release-blocking):
Remaining Stream C surfaces (hardening, not release-blocking)**none of these shipped either**:
- ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired.
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
+21 -2
View File
@@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{
| Stack dir | Purpose |
|---|---|
| `/opt/otopcua-mssql` | central SQL (always-on) |
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` | driver fixtures |
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` | driver fixtures |
| `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) |
| `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 |
@@ -69,9 +69,28 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
| S7 | `otopcua-python-snap7` | `10.100.0.35:1102` | `docker compose --profile s7_1500 up -d` |
| OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` | `opc.tcp://10.100.0.35:50000` | `docker compose up -d` |
| FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) |
| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<pw> docker compose up -d` (stack `/opt/otopcua-mqtt`) |
| MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth |
| MTConnect | `mtconnect/agent:2.7.0.12` + `python:3.13-alpine` adapter | `http://10.100.0.35:5000` | `docker compose up -d --wait` |
**Endpoint overrides** point a suite at a real PLC instead of the sim:
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT`.
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` ·
`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT`.
> **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous
> fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass),
> so it will not even start until `./gen-fixture-material.sh` has written `secrets/` (password file +
> CA + server cert/key, gitignored) and `MQTT_FIXTURE_USERNAME`/`MQTT_FIXTURE_PASSWORD` are exported
> at `docker compose up`. The live suite additionally wants `MQTT_FIXTURE_CA_CERT` pointing at a local
> copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`.
> It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md).
> **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone
> Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service
> (a stdlib SHDR feeder, `Docker/adapter.py`) is the data source; the Agent dials out to it. Its
> suite also probes over **HTTP**, not TCP — an Agent's port accepts connections before its device
> model is parsed. Full detail + the seeded device model:
> [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
> **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down`
> may leave a stale container bound. Force-remove before switching profiles:
@@ -29,9 +29,31 @@ public enum BrowseNodeKind
/// <c>AlarmExtension</c> primitive). The picker pre-fills a default native-alarm <c>alarm</c> object
/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware
/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce.</param>
/// <param name="AddressFields">
/// The <b>structured</b> address this leaf binds by, as <c>TagConfig</c> key → value in the
/// driver's own key vocabulary, for a driver whose address is a <i>tuple</i> rather than a single
/// reference string. Null (the default) for every driver whose address IS the
/// <see cref="BrowseNode.NodeId"/> — nothing changes for them.
/// <para>
/// It exists because a tuple cannot be recovered from an id string in general. MQTT/Sparkplug
/// is the case in point: a browse node id is
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name may itself contain <c>/</c>
/// (<c>Node Control/Rebirth</c>) — so the session keeps the decomposition it already has and
/// <b>states</b> it here, rather than the AdminUI's browse-commit mapper re-deriving it by
/// splitting the id and hoping. Same discipline as the v3 address space carrying
/// <c>AddressSpaceRealm</c> explicitly instead of parsing it out of a NodeId.
/// </para>
/// <para>
/// The producer states the binding <em>shape</em> too, by which keys it emits — so a consumer
/// never has to infer a driver's sub-mode from the tree's geometry. Consumers must read the
/// keys they know and ignore the rest; this is a description, not a config blob to splat into
/// a persisted TagConfig.
/// </para>
/// </param>
public sealed record AttributeInfo(
string Name,
string DriverDataType,
bool IsArray,
string SecurityClass,
bool IsAlarm = false);
bool IsAlarm = false,
IReadOnlyDictionary<string, string>? AddressFields = null);
@@ -35,3 +35,66 @@ public interface IBrowseSession : IAsyncDisposable
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
/// <summary>
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
/// observation window can fill without waiting for the next natural announcement. Implemented
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
/// certificates.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
/// property is load-bearing: a picker opened by an operator runs against a live production
/// broker. It is a separate interface, rather than an optional member on
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
/// question a caller cannot forget to ask.
/// </para>
/// <para>
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
/// cannot check that itself, since it holds no user identity.
/// </para>
/// </remarks>
public interface IRebirthCapableBrowseSession : IBrowseSession
{
/// <summary>
/// Whether this <i>particular</i> session can actually re-announce — the runtime half of the
/// type question, and the one a UI must ask before offering the affordance.
/// </summary>
/// <remarks>
/// One session class may serve several protocol shapes: the MQTT browser opens the same session
/// type for Plain and for Sparkplug B, and a Plain MQTT window publishes <b>nothing, ever</b> —
/// there is no plain-MQTT re-announce to offer. Implementing the interface therefore means "this
/// session type may re-announce"; this property means "this instance will". A UI gating on the
/// type alone would draw a button that can only ever throw.
/// <para>
/// This is <b>not</b> an authorization signal — see the interface remarks. False here hides
/// the affordance; the caller still authorizes before invoking
/// <see cref="RequestRebirthAsync"/>, and the implementation still refuses a call it cannot
/// serve.
/// </para>
/// </remarks>
bool RebirthAvailable { get; }
/// <summary>
/// Asks the addressed remote peer(s) to re-announce themselves.
/// </summary>
/// <param name="scope">
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
/// </param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
/// <exception cref="InvalidOperationException">
/// The scope resolves to no target, or to more targets than the implementation will fan out to
/// in one action. Nothing is published in either case.
/// </exception>
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
}
@@ -13,6 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// <param name="LastError">Latest error message; null when none.</param>
/// <param name="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
/// <param name="PublishedUtc">Timestamp this snapshot was published.</param>
/// <param name="RediscoveryNeededUtc">
/// When the driver last raised <c>IRediscoverable.OnRediscoveryNeeded</c> — i.e. it observed that the
/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect
/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not
/// implement <c>IRediscoverable</c>.
/// <para><b>Advisory only.</b> The served address space is NOT rebuilt in response — raw tags are
/// authored explicitly through the <c>/raw</c> browse-commit flow. This is a prompt for an operator
/// to re-browse the device and commit whatever changed.</para>
/// </param>
/// <param name="RediscoveryReason">
/// The driver-supplied reason string from the same event (e.g. <c>"deploy-time-changed"</c>), shown
/// to the operator alongside the prompt. Null when <paramref name="RediscoveryNeededUtc"/> is null.
/// </param>
public sealed record DriverHealthChanged(
string ClusterId,
string DriverInstanceId,
@@ -20,7 +33,9 @@ public sealed record DriverHealthChanged(
DateTime? LastSuccessfulReadUtc,
string? LastError,
int ErrorCount5Min,
DateTime PublishedUtc)
DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null,
string? RediscoveryReason = null)
{
/// <summary>
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
@@ -68,6 +68,8 @@ message DriverHealth {
optional string last_error = 5; // nullable in the record
int32 error_count_5min = 6;
google.protobuf.Timestamp published_utc = 7;
google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? absent Timestamp encodes null
optional string rediscovery_reason = 9; // nullable in the record
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged.
@@ -41,9 +41,77 @@ 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><b>Two of the three surfaces are checked here.</b> The third — a node's
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
/// never enters the artifact.</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 = SqlCredentialGuard.ForbiddenKey;
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 (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) 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 (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) 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>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
@@ -0,0 +1,139 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// The one place that decides whether a persisted config blob carries a literal Sql
/// <c>connectionString</c> (Gitea #498 / #499).
/// </summary>
/// <remarks>
/// <para>
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
/// be written even though the runtime ignores it.
/// </para>
/// <para>
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
/// enforcement points:
/// <list type="bullet">
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
/// <c>DraftValidator</c>.</item>
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
/// single rule about a value that is not part of the artifact at all. More to the point, a
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
/// blocking a deploy would not stop the credential being stored. It is already in the
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
/// point where "refuse to store it" is literally true.</item>
/// </list>
/// </para>
/// <para>
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
/// would start refusing configs that are legitimate today for drivers that never made this
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
/// gains an indirect-credential contract of its own.
/// </para>
/// </remarks>
public static class SqlCredentialGuard
{
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
public const string ForbiddenKey = "connectionString";
/// <summary>
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
/// its top level.
/// </summary>
/// <remarks>
/// 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 exists to catch.
/// </remarks>
/// <param name="configJson">The config blob to inspect.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
public static bool CarriesLiteralConnectionString(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return false;
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
}
catch (JsonException)
{
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
// throwing here would turn a formatting mistake into a credential-guard failure.
return false;
}
}
/// <summary>
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
/// override object carries a literal connection string, restricted to instances that are actually
/// Sql drivers.
/// </summary>
/// <remarks>
/// The override map is shaped <c>{ "&lt;DriverInstanceId&gt;": { …driver config keys… } }</c> and is
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
/// itself.
/// </remarks>
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
/// violations.</param>
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
public static IReadOnlyList<string> FindNodeOverrideViolations(
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
{
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
{
return [];
}
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
try
{
using var doc = JsonDocument.Parse(overridesJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
List<string>? offenders = null;
foreach (var entry in doc.RootElement.EnumerateObject())
{
if (!sqlIds.Contains(entry.Name)) continue;
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
(offenders ??= []).Add(entry.Name);
}
return (IReadOnlyList<string>?)offenders ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
/// case-insensitively.</summary>
private static bool HasTopLevelKey(JsonElement obj, string key)
{
foreach (var property in obj.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
@@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
/// constants and the set the driver factories actually register, so a rename on
/// either side breaks the build's test gate. Only constants for
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
/// constant when its factory is wired in.
/// <b>currently-registered</b> factories belong here — a driver adds its own constant
/// when its factory is wired in. (This used to name Calculation as the example of a
/// not-yet-registered driver; it registers in <c>DriverFactoryBootstrap</c> like the rest.)
/// </para>
/// </remarks>
public static class DriverTypeNames
@@ -53,6 +53,14 @@ 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>MQTT / Sparkplug B broker-subscription driver.</summary>
public const string Mqtt = "Mqtt";
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
public const string MTConnect = "MTConnect";
/// <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 +76,8 @@ public static class DriverTypeNames
OpcUaClient,
Galaxy,
Calculation,
Sql,
Mqtt,
MTConnect,
];
}
@@ -15,11 +15,20 @@ public interface IDriverHealthPublisher
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
/// <param name="health">The current health snapshot.</param>
/// <param name="errorCount5Min">The number of errors observed in the trailing 5-minute window.</param>
/// <param name="rediscoveryNeededUtc">
/// When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, or null if never
/// (or if it is not an <see cref="IRediscoverable"/>). Advisory: it prompts an operator to
/// re-browse, and never rebuilds the served address space.
/// </param>
/// <param name="rediscoveryReason">The driver-supplied reason from that event; null when
/// <paramref name="rediscoveryNeededUtc"/> is null.</param>
void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min);
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null);
}
/// <summary>
@@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min)
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null)
{ /* no-op */ }
}
@@ -38,4 +38,32 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
/// </summary>
bool SupportsOnlineDiscovery => false;
/// <summary>
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
/// two sets detects that the remote's tag set has drifted from what an operator authored.
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
/// authored refs are directly comparable to what it discovers.</para>
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
/// replays authored tags from config rather than browsing the backend, the comparison is a
/// tautology — it would report "no drift" forever and read as reassurance.</para>
/// </summary>
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
/// <summary>
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
/// shows an operator their existing tags alongside what the device offers.
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
/// as reassurance.</para>
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
/// from the Agent's probe model — so both directions are detectable.</para>
/// </summary>
bool DiscoveryStreamIncludesAuthoredTags => false;
}
@@ -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.
@@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
/// controller-browse leaves also use.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_declaredTags.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
/// pre-declared tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -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>
@@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
private readonly AbLegacyDriverOptions _options;
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
private AbLegacyDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbLegacyTagFactory _tagFactory;
private readonly ILogger<AbLegacyDriver> _logger;
@@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <inheritdoc />
public string DriverType => "AbLegacy";
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green.
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
@@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
return new AbLegacyDriver(
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
tagFactory: null,
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
}
/// <summary>
/// Parses an AB Legacy <c>DriverConfig</c> JSON document into typed options. Extracted so
/// <see cref="AbLegacyDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
/// (Gitea #516) instead of serving the options it was constructed with — which silently discarded
/// every operator edit while the deployment still sealed green.
/// </summary>
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>The parsed <see cref="AbLegacyDriverOptions"/>.</returns>
internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
};
return new AbLegacyDriver(
options, driverInstanceId,
tagFactory: null,
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
return options;
}
/// <summary>
@@ -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
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
private FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
private IFocasClientFactory _clientFactory;
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
/// was constructed directly (tests, or a caller supplying its own backend), which keeps the
/// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
private readonly Func<string, FocasDriverBinding>? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
@@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
/// <param name="logger">Optional logger instance.</param>
/// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
/// supplied by the factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct
/// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
/// options + backend.</param>
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null,
ILogger<FocasDriver>? logger = null)
ILogger<FocasDriver>? logger = null,
Func<string, FocasDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_rebind = rebind;
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
backoffCap: PollBackoffCap);
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
/// without a config — those keep the constructor-supplied options + backend.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <inheritdoc />
public string DriverType => "FOCAS";
/// <inheritdoc />
/// <summary>
/// Opens the configured CNC handles and builds the authored tag table.
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
/// #516 pass.</para>
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
/// remains the outer guarantee.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
try
{
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
// parse and assigned together — a partial adoption would poll a new device/tag set through the
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
_options = binding.Options;
_clientFactory = binding.ClientFactory;
}
// Fail fast if the factory is a stub/unimplemented backend — the operator must
// see an actionable error at init rather than a phantom-Healthy driver that fails
// every read/write/subscribe silently.
@@ -467,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
var binding = ParseBinding(driverInstanceId, driverConfigJson);
return new FocasDriver(
binding.Options,
driverInstanceId,
binding.ClientFactory,
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
// config-derived dependency on a reinitialize — options AND the backend client factory
// together (#516). Re-parsing options alone would run a new device/tag set against the old
// backend, which is worse than not re-parsing at all.
rebind: json => ParseBinding(driverInstanceId, json));
}
/// <summary>
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
/// <see cref="CreateInstance"/>.
/// </summary>
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>The parsed options + backend factory.</returns>
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
};
var clientFactory = BuildClientFactory(dto, driverInstanceId);
return new FocasDriver(options, driverInstanceId, clientFactory);
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
}
/// <summary>
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? Interval { get; init; }
}
}
/// <summary>
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
@@ -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,266 @@
using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
/// <see cref="DriverDataType" /> plus the array shape that
/// <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
/// expect. Returned by value (a readonly record struct) so the discovery loop and the
/// AdminUI editor can call the inference on a hot path without allocating.
/// </summary>
/// <param name="DataType">The scalar element type of the observation.</param>
/// <param name="IsArray">
/// <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
/// observation carries a vector of samples rather than a single value.
/// </param>
/// <param name="ArrayDim">
/// The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
/// model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
/// (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
/// </param>
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
/// <summary>
/// Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
/// representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
/// no I/O, no logging, no mutable state.
/// </summary>
/// <remarks>
/// <para>
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
/// AdminUI typed tag editor (shows the inferred type and offers an override).
/// </para>
/// <para>
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
/// defines hundreds of <c>type</c> values with more added each revision. This is therefore a
/// <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
/// The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
/// fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
/// always round-trips and the author can retype it. So every default leans to
/// <c>String</c> except where the standard itself guarantees a number.
/// </para>
/// <para>
/// <b>Defaulting rule, per category</b> (design §3.3):
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
/// unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
/// category as a continuously-varying measured quantity, so the category alone is
/// the guarantee — a missing <c>units</c> attribute is a gap in the device model,
/// not evidence that the observation is text. <c>Float64</c> rather than an integer
/// type because a double parse accepts both <c>3</c> and <c>3.5</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
/// exception list (<see cref="NumericEventTypes" />) of standard types that are
/// defined as integers. The overwhelming majority of EVENT types are controlled
/// vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
/// text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
/// deliberately short — each entry is a coercion risk, while every omission is
/// merely a string the author can retype.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
/// is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
/// <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
/// Probe XML in the wild is inconsistent; with no category we have no evidence the
/// observation is numeric, so we pick the type that cannot fail to parse.
/// </description>
/// </item>
/// </list>
/// <para>
/// Representation overrides the category where the two disagree about shape:
/// <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
/// <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
/// defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
/// </para>
/// <para>
/// <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
/// <c>_</c>, <c>-</c> and whitespace are ignored wherever they appear, so
/// <c>PART_COUNT</c> ≡ <c>PartCount</c> ≡ <c>part-count</c> ≡ <c>partcount</c>. This is not
/// mere leniency: <b>MTConnect spells the same concept two different ways in two different
/// documents</b>. The Devices document (<c>/probe</c>) writes <c>DataItem@type</c> in
/// UPPER_SNAKE (<c>PART_COUNT</c>, <c>POSITION</c>, <c>PATH_FEEDRATE</c>), while the Streams
/// documents (<c>/current</c>, <c>/sample</c>) name the observation element in PascalCase
/// (<c>PartCount</c>, <c>Position</c>). Discovery reads the <i>probe</i> spelling, and plain
/// case-insensitivity does not bridge the underscore — so matching only the PascalCase
/// spelling types every real agent's <c>PART_COUNT</c> as <see cref="DriverDataType.String" />
/// while every unit test using the sketch's PascalCase spelling stays green.
/// </para>
/// </remarks>
public static class MTConnectDataTypeInference
{
private const string CategorySample = "SAMPLE";
private const string CategoryEvent = "EVENT";
private const string RepresentationTimeSeries = "TIME_SERIES";
private const string RepresentationDataSet = "DATA_SET";
private const string RepresentationTable = "TABLE";
/// <summary>
/// The <c>EVENT</c> types the standard defines as integers — the exception list to the
/// "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
/// every agent reports it as a parseable integer, and being wrong costs Bad quality.
/// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
/// both are mapped so a current-version agent is not silently mistyped.
/// <para>
/// The entries are written in the Streams (PascalCase) spelling, but the set's comparer
/// is <see cref="SeparatorInsensitiveComparer" />, so the probe document's
/// <c>PART_COUNT</c> / <c>LINE_NUMBER</c> hit the same entries. Adding a spelling variant
/// here would be redundant, not additional coverage.
/// </para>
/// </summary>
private static readonly FrozenSet<string> NumericEventTypes =
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
/// <summary>
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
/// metadata. See the type-level remarks for the full rule and its rationale.
/// </summary>
/// <param name="category">
/// The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
/// Case-insensitive; an unknown, blank, or <c>null</c> value yields
/// <see cref="DriverDataType.String" />.
/// </param>
/// <param name="type">
/// The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
/// <c>PART_COUNT</c> / <c>POSITION</c> or the streams' <c>PartCount</c> / <c>Position</c>.
/// Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
/// or <c>null</c> value falls back to the category default.
/// </param>
/// <param name="units">
/// The declared engineering units, if any. Accepted for signature stability and because every
/// call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
/// definition of the category, so units add no discriminating power there, and treating a
/// units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
/// </param>
/// <param name="representation">
/// The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
/// <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
/// </param>
/// <param name="sampleCount">
/// The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
/// <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
/// value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
/// </param>
/// <returns>The inferred scalar type plus array shape.</returns>
public static MTConnectInferredType Infer(
string? category,
string? type = null,
string? units = null,
string? representation = null,
int? sampleCount = null)
{
_ = units; // See the parameter doc: intentionally not load-bearing in v1.
var trimmedCategory = category.AsSpan().Trim();
var trimmedRepresentation = representation.AsSpan().Trim();
// A key-value observation is never a number, whatever the category claims. Checked first so
// a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
if (Matches(trimmedCategory, CategorySample))
{
// TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
if (Matches(trimmedRepresentation, RepresentationTimeSeries))
return new MTConnectInferredType(
DriverDataType.Float64,
IsArray: true,
ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);
return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
}
if (Matches(trimmedCategory, CategoryEvent))
{
// No Trim() here: the set's comparer already ignores whitespace and separators
// wherever they appear, so the raw attribute value is looked up allocation-free.
var dataType = type is { Length: > 0 } && NumericEventTypes.Contains(type)
? DriverDataType.Int64
: DriverDataType.String;
return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
}
// CONDITION (a state word) and every unknown / blank / missing category.
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
}
private static bool Matches(ReadOnlySpan<char> value, string expected)
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Ordinal-ignore-case equality that additionally skips <c>_</c>, <c>-</c> and whitespace
/// wherever they occur, so the probe document's <c>PART_COUNT</c> and the streams
/// document's <c>PartCount</c> are one key. Backs <see cref="NumericEventTypes" />.
/// </summary>
/// <remarks>
/// Comparing through a comparer rather than normalising the input keeps the lookup
/// allocation-free on the discovery hot path: no <c>string.Replace</c>, no
/// <c>Trim()</c>, no temporary buffer — the raw attribute value from the parser is passed
/// straight to <see cref="FrozenSet{T}.Contains" />.
/// </remarks>
private sealed class SeparatorInsensitiveComparer : IEqualityComparer<string>
{
/// <summary>The single shared instance; the comparer is stateless.</summary>
internal static readonly SeparatorInsensitiveComparer Instance = new();
private SeparatorInsensitiveComparer() { }
/// <inheritdoc />
public bool Equals(string? x, string? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
int i = 0, j = 0;
while (true)
{
while (i < x.Length && IsSeparator(x[i])) i++;
while (j < y.Length && IsSeparator(y[j])) j++;
if (i == x.Length || j == y.Length) return i == x.Length && j == y.Length;
if (char.ToUpperInvariant(x[i]) != char.ToUpperInvariant(y[j])) return false;
i++;
j++;
}
}
/// <inheritdoc />
public int GetHashCode(string obj)
{
// Must hash exactly the characters Equals compares, or two equal spellings land in
// different buckets and the set silently misses.
var hash = new HashCode();
foreach (var c in obj)
{
if (IsSeparator(c)) continue;
hash.Add(char.ToUpperInvariant(c));
}
return hash.ToHashCode();
}
private static bool IsSeparator(char c) => c is '_' or '-' || char.IsWhiteSpace(c);
}
}
@@ -0,0 +1,134 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// MTConnect Agent driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. The driver polls a remote MTConnect Agent's REST endpoints
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>) rooted at <see cref="AgentUri"/> and maps
/// each returned DataItem into an OPC UA variable per <see cref="MTConnectTagDefinition"/>.
/// </summary>
public sealed class MTConnectDriverOptions
{
/// <summary>
/// Gets the MTConnect Agent's base URI (e.g. <c>http://agent:5000</c>). Required — the
/// driver appends the standard Agent request paths (<c>/probe</c>, <c>/current</c>,
/// <c>/sample</c>) to this base.
/// </summary>
public required string AgentUri { get; init; }
/// <summary>
/// Optional device-name scope. When set, requests are narrowed to
/// <c>{AgentUri}/{DeviceName}/...</c> so a multi-device Agent only serves one device's
/// DataItems through this driver instance. Default <c>null</c> = agent-wide (all devices).
/// </summary>
public string? DeviceName { get; init; }
/// <summary>
/// Per-call HTTP deadline, in milliseconds, applied to every Agent request
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). Default <c>5000</c> — long enough for
/// a large device probe response over a LAN, short enough that a hung Agent surfaces as a
/// failed poll rather than wedging the driver.
/// </summary>
public int RequestTimeoutMs { get; init; } = 5000;
/// <summary>
/// The <c>interval</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
/// streaming request — the minimum time the Agent waits between successive chunks of the
/// response. Default <c>1000</c>; must be non-zero so the sample pump cannot spin the
/// connection into a busy-loop.
/// </summary>
public int SampleIntervalMs { get; init; } = 1000;
/// <summary>
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
/// MTConnect Agent's own conventional default.
/// </summary>
public int SampleCount { get; init; } = 1000;
/// <summary>
/// The <c>heartbeat</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
/// streaming request — the Agent sends an empty chunk after this much idle time so the
/// client can detect a silently-stalled connection. Default <c>10000</c>, matching the
/// MTConnect Agent's own conventional default; must be non-zero or a quiet connection can
/// never be distinguished from a dead one.
/// </summary>
public int HeartbeatMs { get; init; } = 10000;
/// <summary>
/// The authored tags this driver instance serves — one <see cref="MTConnectTagDefinition"/>
/// per tag, keyed by the DataItem <c>id</c> it binds
/// (<see cref="MTConnectTagDefinition.FullName"/>). The factory config DTO carries these and
/// builds them into the options; the driver indexes them to know each tag's target
/// <see cref="MTConnectTagDefinition.DriverDataType"/> when coercing an Agent observation
/// (whose wire form is always text) into a published value.
/// <para>
/// Defaults to <b>empty, never <c>null</c></b>: a driver instance may legitimately be
/// authored with no tags yet, and a null collection here would surface as a
/// NullReferenceException at deploy time from operator-authored config.
/// </para>
/// </summary>
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
/// <summary>
/// <b>The v3 data-plane binding: the authored raw tags the deploy artifact delivers.</b> Each
/// <see cref="RawTagEntry"/> pairs the tag's <b>RawPath</b> — the driver's wire reference for
/// read / subscribe / publish, and the key <c>DriverHostActor</c> routes published values by —
/// with the driver-specific <c>TagConfig</c> blob naming the Agent DataItem <c>id</c> it binds.
/// Injected into every driver's merged config by <c>DriverDeviceConfigMerger</c>, so this is the
/// collection a deployed instance actually serves from.
/// <para>
/// <b>Relationship to <see cref="Tags"/>.</b> The driver builds ONE observation index and
/// ONE <c>RawPath → dataItemId</c> table from both collections:
/// <list type="bullet">
/// <item>A tag in <c>RawTags</c> is reachable by its RawPath (production). Its coercion
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
/// its coercion type to the index.</item>
/// </list>
/// Both default to empty, so neither collection is required and a driver instance
/// authored with no tags yet starts cleanly.
/// </para>
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
/// <c>OnHostStatusChanged</c> on Running ↔ Stopped transitions.
/// </summary>
public MTConnectProbeOptions Probe { get; init; } = new();
/// <summary>
/// Reconnect backoff settings used after a failed Agent request or a dropped
/// <c>/sample</c> streaming connection.
/// </summary>
public MTConnectReconnectOptions Reconnect { get; init; } = new();
}
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
public sealed class MTConnectProbeOptions
{
/// <summary>Gets a value indicating whether probing is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the interval between probe requests.</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Gets the probe request timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>Geometric-backoff settings for the post-failure reconnect loop, mirroring <c>ModbusReconnectOptions</c>.</summary>
public sealed class MTConnectReconnectOptions
{
/// <summary>Delay before the first reconnect attempt, in milliseconds. Default <c>0</c> = immediate.</summary>
public int MinBackoffMs { get; init; } = 0;
/// <summary>Upper bound on the geometric backoff sequence, in milliseconds.</summary>
public int MaxBackoffMs { get; init; } = 30000;
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
public double BackoffMultiplier { get; init; } = 2.0;
}
@@ -0,0 +1,49 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// One MTConnect-backed OPC UA variable. Each authored tag binds a single Agent DataItem by
/// its <c>id</c> attribute (<see cref="FullName"/>); the driver looks up the observation's
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
/// </summary>
/// <param name="FullName">
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
/// there is no corresponding write path.
/// </param>
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
/// <param name="IsArray">
/// When <c>true</c>, the tag is exposed as an OPC UA array (e.g. an MTConnect
/// <c>PathPosition</c> / vector-valued sample). Default <c>false</c> = scalar.
/// </param>
/// <param name="ArrayDim">
/// Element count when <see cref="IsArray"/> is <c>true</c>; ignored for scalar tags. Default
/// <c>0</c>.
/// </param>
/// <param name="MtCategory">
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
/// the value-mapping path. Default <c>null</c>.
/// </param>
/// <param name="MtType">
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="MtSubType">
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="Units">
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
/// </param>
public sealed record MTConnectTagDefinition(
string FullName,
DriverDataType DriverDataType,
bool IsArray = false,
int ArrayDim = 0,
string? MtCategory = null,
string? MtType = null,
string? MtSubType = null,
string? Units = null);
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- Pure records + the MTConnect data-type inference table, shared by the driver, browse-commit,
and the AdminUI typed tag editor. No backend NuGet — only the zero-dependency Core.Abstractions
leaf (DriverDataType etc.), mirroring Driver.Modbus.Contracts / Driver.AbCip.Contracts. -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,135 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
/// design — every method returns/yields the plain DTOs in <c>MTConnectDtos.cs</c>, never a
/// TrakHound type, an <c>XElement</c>, or any other wire/library artifact. That is what lets
/// every driver behaviour above this seam (Tasks 613) be unit-tested against a fake serving
/// canned probe/current/sample XML, with no sockets involved.
/// </summary>
/// <remarks>
/// <para>
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
/// <c>MTConnectStreamsParser</c>) plus a <c>multipart/x-mixed-replace</c> frame reader for the
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
/// </para>
/// <para>
/// <b>Why the seam is <see cref="IDisposable"/> rather than
/// <see cref="IAsyncDisposable"/>.</b> The driver obtains its client through a
/// <c>Func&lt;MTConnectDriverOptions, IMTConnectAgentClient&gt;</c> and must release it on
/// <c>ShutdownAsync</c> and on every endpoint-changing <c>ReinitializeAsync</c>. Declaring
/// disposal here — instead of the driver doing <c>as IDisposable</c> + a null-conditional
/// call — is deliberate: that idiom silently no-ops against any future non-disposable
/// implementation, and the thing it would leak is an <see cref="HttpClient"/> and its socket
/// handler, once per re-deploy, for the life of the process.
/// </para>
/// <para>
/// Synchronous disposal is the honest shape because <i>every</i> resource behind this seam is
/// synchronously disposable — two <see cref="HttpClient"/>s and a
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
/// into an ordinary <see cref="OperationCanceledException"/>.
/// </para>
/// </remarks>
public interface IMTConnectAgentClient : IDisposable
{
/// <summary>
/// Issues an Agent <c>/probe</c> request and returns the parsed device hierarchy. Called
/// once at driver initialization; the result is cached and later walked to build the OPC UA
/// browse tree, where each <see cref="MTConnectDataItem.Id"/> becomes a tag's FullName.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
/// <summary>
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
/// data item. Used both to prime the observation index at startup and to re-baseline after
/// a detected sequence gap in <see cref="SampleAsync"/>.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
/// <summary>
/// Opens an Agent <c>/sample</c> long-poll stream starting at sequence <paramref name="from"/>
/// and yields one <see cref="MTConnectStreamsResult"/> per multipart chunk the Agent sends.
/// </summary>
/// <remarks>
/// <para>
/// <b>The only way this enumeration ends without throwing is <paramref name="ct"/> being
/// cancelled.</b> Every other way a stream can stop — the connection dropping, the Agent
/// writing its closing boundary, or the endpoint turning out not to stream at all —
/// throws an <see cref="MTConnectStreamException"/> naming which. A consumer therefore
/// never has to treat "the enumeration finished" as an ambiguous signal, and cannot
/// silently lose its subscription to a data path that quietly stopped (#485). See
/// <see cref="MTConnectStreamEndedException"/> (transient — reconnect) and
/// <see cref="MTConnectStreamNotSupportedException"/> (configuration — reconnecting will
/// reproduce it forever).
/// </para>
/// <para>
/// <b>Advancing the cursor.</b> After each chunk, the caller's next expected sequence is
/// that chunk's <see cref="MTConnectStreamsResult.NextSequence"/> — not the
/// <paramref name="from"/> this call was opened with. Feed that running value to
/// <see cref="IsSequenceGap"/> and, on reconnect, to a fresh <c>SampleAsync</c>.
/// </para>
/// </remarks>
/// <param name="from">The sequence number to resume streaming from.</param>
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
/// <paramref name="expectedFrom"/>, meaning observations between the two were evicted before
/// the caller could read them and an incremental update would silently skip values.
/// </summary>
/// <remarks>
/// <para>
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
/// against the original <c>from</c> instead reports a gap on every chunk once the
/// Agent's buffer has rolled past it — an endless <c>/current</c> re-baseline storm
/// against a perfectly healthy stream.
/// </para>
/// <para>
/// Equality is not a gap: <c>firstSequence == expectedFrom</c> is the ordinary
/// contiguous case where the Agent simply had nothing older to send.
/// </para>
/// <para>
/// <b>This check alone does NOT cover every ring-buffer overflow.</b> It can only judge
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
/// document served under <b>HTTP 200</b>, which this client surfaces as an
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
/// resume from that snapshot's <c>NextSequence</c>) just like a detected gap.
/// </para>
/// </remarks>
/// <param name="expectedFrom">The sequence the caller expected this chunk to start at.</param>
/// <param name="chunk">The chunk the Agent answered with.</param>
/// <returns>
/// <c>true</c> when observations were lost and the caller must re-baseline via
/// <see cref="CurrentAsync"/> rather than trusting an incremental update.
/// </returns>
static bool IsSequenceGap(long expectedFrom, MTConnectStreamsResult chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
return chunk.FirstSequence > expectedFrom;
}
}
@@ -0,0 +1,726 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
/// returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
/// so a throwaway instance is safe to construct — the universal discovery browser's
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
/// nothing until it is enumerated.
/// </para>
/// <para>
/// <b>Two <see cref="HttpClient"/>s, and the split is deliberate.</b>
/// <see cref="HttpClient.Timeout"/> is a per-instance, whole-response deadline, so a single
/// client cannot serve both legs: the unary <c>/probe</c> and <c>/current</c> calls must be
/// bounded by <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, while <c>/sample</c> is
/// a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
/// R2-01 says must never happen — so the streaming leg gets its own client with
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below). In
/// production each client owns its own handler and connection pool; in tests both are built
/// over one injected handler, which the caller owns.
/// </para>
/// <para>
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
/// run under a <see cref="CancellationTokenSource"/> linked to the caller's token and
/// cancelled after <c>RequestTimeoutMs</c>. The streaming leg bounds its <i>header</i> phase
/// the same way (a peer that accepts the connection and never answers must not wedge the
/// pump), then hands the body to a <b>heartbeat watchdog</b>: MTConnect Agents emit a
/// keep-alive chunk every <see cref="MTConnectDriverOptions.HeartbeatMs"/> precisely so a
/// client can tell "quiet but alive" from "dead", so every body read is bounded by a
/// two-missed-heartbeat window and a silent Agent surfaces as a
/// <see cref="TimeoutException"/>. This is the exact shape of this repo's S7 frozen-peer
/// production defect (arch-review R2-01) — an async read that ignores the socket deadline —
/// and the watchdog is what closes it here.
/// </para>
/// <para>
/// <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
/// A <c>0</c> must never come to mean "wait forever" (arch-review 01/S-6), and the three
/// <c>/sample</c> query knobs additionally reach the wire: an Agent answers
/// <c>heartbeat=0</c> with an <c>MTConnectError</c> under HTTP 200, which would otherwise
/// surface as a parse failure that never names the offending config key.
/// </para>
/// <para>
/// <b>Disposal is safe against an in-flight enumeration.</b> Task 9 re-initializes the
/// driver by disposing and rebuilding this client, which can happen while a pump is still
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
/// caller is written to expect, every request runs under a token linked to an internal
/// dispose token, so disposal surfaces as an ordinary
/// <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
/// status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
/// <see cref="MTConnectStreamException"/> for why the last of those is an exception rather
/// than a normal end of enumeration. <c>InitializeAsync</c> (Task 9) is what catches these
/// and moves the driver to Faulted.
/// </para>
/// </remarks>
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly HttpClient _http;
private readonly HttpClient _streamHttp;
private readonly CancellationTokenSource _disposeCts = new();
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
private readonly TimeSpan _streamIdleTimeout;
private bool _disposed;
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
/// <param name="logger">
/// Optional logger. Used only for conditions an operator needs to see but which are not
/// failures — today, the degraded <c>/sample</c> framing mode.
/// </param>
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
/// <see cref="MTConnectDriverOptions.HeartbeatMs"/>,
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/> or
/// <see cref="MTConnectDriverOptions.SampleCount"/> is not positive.
/// </exception>
public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
: this(options, handler: null, logger)
{
}
/// <summary>
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
/// sends through the supplied handler so the request/response round trip can be exercised
/// with no socket. The caller retains ownership of <paramref name="handler"/>.
/// </summary>
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
RequirePositive(
options.RequestTimeoutMs,
nameof(MTConnectDriverOptions.RequestTimeoutMs),
"a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
RequirePositive(
options.HeartbeatMs,
nameof(MTConnectDriverOptions.HeartbeatMs),
"it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
RequirePositive(
options.SampleIntervalMs,
nameof(MTConnectDriverOptions.SampleIntervalMs),
"it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
RequirePositive(
options.SampleCount,
nameof(MTConnectDriverOptions.SampleCount),
"it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");
_logger = logger;
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
// HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
_sampleQuerySuffix = string.Create(
CultureInfo.InvariantCulture,
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = _requestTimeout;
_streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
// See the class remarks: a long poll must outlive the unary deadline by design, and the
// heartbeat watchdog — not this property — is what keeps it bounded.
_streamHttp.Timeout = Timeout.InfiniteTimeSpan;
}
/// <inheritdoc/>
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
// call, so the parse below reads from memory — no network read can outlive the deadline.
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return MTConnectProbeParser.Parse(body);
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return MTConnectStreamsParser.Parse(body);
}
/// <inheritdoc/>
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from,
[EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var uri = BuildSampleUri(from);
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
var live = lifetime.Token;
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);
await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);
var boundary = ResolveMultipartBoundary(response, uri);
if (boundary is null)
{
// Not a multipart stream: the Agent answered with a single document, which means it
// ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
// exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
// more useful than ours — then report the misconfiguration. The parsed snapshot is
// deliberately NOT yielded: one document followed by a healthy-looking finished stream
// is how a dead data path disguises itself as a working one (#485).
var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
_ = MTConnectStreamsParser.Parse(single);
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
}
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
var delivered = 0L;
while (true)
{
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
if (part is null)
{
// The caller cancelling is the ONE contracted way out; anything else is reported.
live.ThrowIfCancellationRequested();
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
}
// A frame carrying nothing but whitespace is transport filler, not a document. The
// Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
// caller's `from` behind the Agent's buffer.
if (string.IsNullOrWhiteSpace(part))
{
continue;
}
delivered++;
yield return MTConnectStreamsParser.Parse(part);
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
// than a disposed handler.
_disposeCts.Cancel();
_http.Dispose();
_streamHttp.Dispose();
_disposeCts.Dispose();
}
private static void RequirePositive(int value, string optionName, string because)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MTConnectDriverOptions),
value,
$"{optionName} must be positive; {because}.");
}
}
private Uri BuildSampleUri(long from) =>
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
UriKind.Absolute);
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
{
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
// Neither the caller nor disposal cancelled, so this is our own deadline (or
// HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
// canceled" tells an operator nothing about which Agent stopped answering.
throw TimedOut(uri);
}
response.EnsureSuccessStatusCode();
return response;
}
/// <summary>
/// Issues the <c>/sample</c> request and returns as soon as the response headers land. Only
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
/// </summary>
private async Task<HttpResponseMessage> SendStreamRequestAsync(
HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _streamHttp
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
response.EnsureSuccessStatusCode();
return response;
}
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var reader = new StreamReader(body, Encoding.UTF8);
try
{
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
}
private TimeoutException TimedOut(Uri uri) =>
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
/// <summary>
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
/// Agent answered with a single (non-multipart) document.
/// </summary>
/// <exception cref="MTConnectStreamNotSupportedException">
/// The response claims to be <c>multipart/*</c> but carries no <c>boundary</c> parameter, so
/// there is nothing to frame it by. Failed fast and named, because the alternative — falling
/// through to whole-body reading of a body that never ends — surfaces much later as a bare
/// watchdog <see cref="TimeoutException"/> that points an operator at the network instead of
/// at the malformed header.
/// </exception>
private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
{
var contentType = response.Content.Headers.ContentType;
if (contentType?.MediaType is null ||
!contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var boundary = contentType.Parameters
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
.Trim('"');
if (string.IsNullOrEmpty(boundary))
{
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
}
return boundary;
}
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
}
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
}
/// <summary>
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
/// incrementally and never buffering more than the part currently in flight.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
/// dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
/// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
/// existing response stream nor be exercised without a listener. See the Task 0
/// correction in the plan.
/// </para>
/// <para>
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
/// a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
/// by length and the chunk is emitted the instant it is complete. Without it there is no
/// way to know a part has ended except by seeing the <i>next</i> boundary, so that path
/// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
/// can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
/// correctness fallback, not the expected path, and it logs a one-shot warning when it
/// engages so the degradation is visible rather than merely slow. (It cannot strand the
/// final part indefinitely — a clean end of stream returns the buffered tail — though a
/// watchdog timeout on a stalled Agent does discard it.)
/// </para>
/// <para>
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
/// parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
/// malfunctioning Agent cannot grow it without limit.
/// </para>
/// </remarks>
private sealed class MultipartStreamReader(
Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
{
private const int MaxBufferBytes = 32 * 1024 * 1024;
private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
private static readonly byte[] LfLf = "\n\n"u8.ToArray();
private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
private byte[] _buffer = new byte[16 * 1024];
private int _length;
private bool _finished;
private bool _warnedNoContentLength;
/// <summary>How the stream ended. Only meaningful once a read has returned <c>null</c>.</summary>
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
/// <summary>
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
/// </summary>
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
{
if (_finished)
{
return null;
}
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
!await EnsureAsync(2, ct).ConfigureAwait(false))
{
return Finish(MTConnectStreamEndReason.ConnectionClosed);
}
// "--boundary--" closes the stream.
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
{
return Finish(MTConnectStreamEndReason.ClosingBoundary);
}
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
if (headerEnd < 0)
{
return Finish(MTConnectStreamEndReason.ConnectionClosed);
}
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
Consume(headerEnd);
byte[]? body;
if (ParseContentLength(headers) is { } declared)
{
body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
}
else
{
WarnOnceAboutMissingContentLength();
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
}
return body is null ? null : Encoding.UTF8.GetString(body);
}
private void WarnOnceAboutMissingContentLength()
{
if (_warnedNoContentLength)
{
return;
}
_warnedNoContentLength = true;
logger?.LogWarning(
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
uri);
}
private string? Finish(MTConnectStreamEndReason reason)
{
_finished = true;
EndReason = reason;
return null;
}
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
{
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
{
Finish(MTConnectStreamEndReason.ConnectionClosed);
return null;
}
var body = _buffer[..declared];
Consume(declared);
return body;
}
private async Task<byte[]?> ReadToNextDelimiterAsync(CancellationToken ct)
{
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
if (next < 0)
{
// EOF with no closing delimiter: whatever is buffered is the last part. Returned
// rather than dropped, so a clean end of stream cannot strand a chunk.
var tail = _buffer[.._length];
_length = 0;
Finish(MTConnectStreamEndReason.ConnectionClosed);
return tail;
}
var body = _buffer[..TrimTrailingLineBreak(next)];
// Leave the delimiter itself in place; the next call's skip consumes it.
Consume(next);
return body;
}
/// <summary>Strips the single line break that terminates a part body before its delimiter.</summary>
private int TrimTrailingLineBreak(int end)
{
if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
{
return end - 2;
}
return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
}
/// <summary>Advances past the next boundary delimiter; <c>false</c> at end of stream.</summary>
private async Task<bool> SkipThroughDelimiterAsync(CancellationToken ct)
{
var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
if (index < 0)
{
return false;
}
Consume(index + _delimiter.Length);
return true;
}
private async Task<int> FindDelimiterAsync(CancellationToken ct)
{
var searchedTo = 0;
while (true)
{
// Restart the scan far enough back that a delimiter split across two reads is still
// found — without the overlap, a boundary straddling a packet boundary is invisible.
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
if (index >= 0)
{
return index;
}
searchedTo = _length;
if (!await FillAsync(ct).ConfigureAwait(false))
{
return -1;
}
}
}
/// <summary>
/// Finds the end of the part's header block — the first blank line after the delimiter.
/// A part with no headers at all is legal, in which case the blank line sits at offset 0.
/// </summary>
private async Task<int> FindHeaderEndAsync(CancellationToken ct)
{
var searchedTo = 0;
while (true)
{
var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));
if (crlf >= 0 && (lf < 0 || crlf <= lf))
{
return crlf + CrLfCrLf.Length;
}
if (lf >= 0)
{
return lf + LfLf.Length;
}
searchedTo = _length;
if (!await FillAsync(ct).ConfigureAwait(false))
{
return -1;
}
}
}
private static int? ParseContentLength(string headers)
{
foreach (var line in headers.Split('\n'))
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var raw = trimmed["content-length:".Length..].Trim();
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
value >= 0
? value
: null;
}
return null;
}
private async Task<bool> EnsureAsync(int count, CancellationToken ct)
{
while (_length < count)
{
if (!await FillAsync(ct).ConfigureAwait(false))
{
return false;
}
}
return true;
}
/// <summary>
/// Reads at least one more byte, bounded by the heartbeat watchdog. Returns <c>false</c>
/// at end of stream and throws <see cref="TimeoutException"/> when the Agent goes silent.
/// </summary>
private async Task<bool> FillAsync(CancellationToken ct)
{
if (_length == _buffer.Length)
{
if (_buffer.Length >= MaxBufferBytes)
{
throw new InvalidDataException(
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
}
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
}
using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
watchdog.CancelAfter(idleTimeout);
int read;
try
{
read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException(
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
}
if (read <= 0)
{
return false;
}
_length += read;
return true;
}
private int IndexOf(byte[] pattern, int start)
{
if (start >= _length)
{
return -1;
}
var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());
return index < 0 ? -1 : index + start;
}
private void Consume(int count)
{
Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
_length -= count;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Static factory registration helper for <see cref="MTConnectDriver"/>. The Host registers it
/// once at startup; the bootstrapper then materialises MTConnect <c>DriverInstance</c> rows from
/// the deployed configuration into live driver instances. Mirrors
/// <c>ModbusDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>There is exactly one parser for the config document.</b> This factory does not carry a
/// config DTO of its own — it delegates to <see cref="MTConnectDriver.ParseOptions"/>, which
/// the driver itself must own because the runtime delivers a config <i>change</i> to a live
/// instance (<c>DriverInstanceActor</c> assigns its driver once and calls
/// <c>ReinitializeAsync(newJson)</c> thereafter). A second DTO here would drift from that
/// one silently, and the drift would first show up at deploy time as an operator's edit
/// being applied differently by the two paths — or not at all.
/// </para>
/// <para>
/// <b>Construction opens nothing.</b> The Wave-0 universal discovery browser builds a
/// throwaway instance through this factory purely to read
/// <c>ITagDiscovery.SupportsOnlineDiscovery</c>, and never initializes it — so a factory
/// that dialled the Agent (or that pre-built an <see cref="IMTConnectAgentClient"/>) would
/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
/// agent-client factory is therefore left null: <see cref="MTConnectDriver"/> builds the
/// production client inside <c>InitializeAsync</c>.
/// </para>
/// <para>
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IRediscoverable"/>) by pattern-matching the object
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
/// narrowed return is how a capability ends up implemented but never dispatched. There is
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
/// </para>
/// </remarks>
public static class MTConnectDriverFactoryExtensions
{
/// <summary>
/// The <c>DriverInstance.DriverType</c> value this factory answers to.
/// </summary>
/// <remarks>
/// <b>Aliased to <see cref="DriverTypeNames.MTConnect"/>, not a literal.</b> The registry key,
/// the instance's self-reported <see cref="MTConnectDriver.DriverType"/>, the probe's
/// <c>DriverType</c>, and the constant every dispatch map keys off are therefore the same
/// symbol — the repo-wide fix for the historical <c>TwinCat</c>/<c>Focas</c> drift, where a
/// driver's own spelling silently diverged from the constant and dispatch maps missed it.
/// Retained as a named const (rather than deleted in favour of the constant) so the existing
/// factory callers and the sibling <c>*DriverFactoryExtensions.DriverTypeName</c> convention
/// keep working; it is now an alias with no independent value.
/// </remarks>
public const string DriverTypeName = DriverTypeNames.MTConnect;
/// <summary>
/// Register the MTConnect factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct
/// an <see cref="ILogger{TCategoryName}"/> per driver instance — without it the driver runs
/// with the null logger (tests and standalone callers stay unchanged).
/// </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>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
public static MTConnectDriver 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.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is null or blank.
/// </exception>
/// <exception cref="InvalidOperationException">
/// The config document is unparseable, deserialises to null, omits the required
/// <c>agentUri</c>, or carries an unauthorable enum/tag value. Throwing is correct at this
/// seam: a deployment carrying such a row must fail rather than register a driver pointed at
/// nothing, and the discovery browser's <c>CanBrowse</c> catches factory exceptions so a
/// half-authored config merely disables the address picker.
/// </exception>
public static MTConnectDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
// The single config authority — see the class remarks for why this is not a second DTO.
// Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
// there means "no config supplied, keep the constructor options", but this factory HAS no
// constructor options to keep, so an empty document is simply a config with no agentUri and
// must fault.
var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
// Named arguments deliberately: the ctor's trailing parameters are all optional, so a
// future insertion could silently re-bind a positional argument to the wrong one.
return new MTConnectDriver(
options,
driverInstanceId,
agentClientFactory: null,
logger: loggerFactory?.CreateLogger<MTConnectDriver>());
}
}
@@ -0,0 +1,237 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
/// single <c>GET {AgentUri}/probe</c> under the supplied deadline and reports success/failure
/// with latency — it does not construct or initialize an <see cref="MTConnectDriver"/> instance.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
/// method builds the full driver options document, including every authored
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
/// </para>
/// <para>
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject.
/// This is the direct consequence of the independent-parsing decision above, judged
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
/// config deploy".
/// </para>
/// <para>
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
/// call would have to re-derive the exact behaviour the client already provides: per-call
/// <see cref="HttpClient.Timeout"/> bounding, a linked-CTS deadline, and — most importantly —
/// correct handling of an Agent's <c>MTConnectError</c> document served under HTTP 200 (so
/// <c>EnsureSuccessStatusCode</c> never fires). <see cref="MTConnectProbeParser"/> already
/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
/// here would either duplicate that logic or under-validate (accepting any 200 response as
/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
/// risk.
/// </para>
/// <para>
/// <b>Never throws</b> (the <see cref="IDriverProbe"/> contract): every failure — unparseable
/// JSON, missing/malformed <c>agentUri</c>, DNS/TCP failure, a non-2xx response, a 200 body
/// that is not a valid <c>MTConnectDevices</c> document, and timeout — becomes
/// <c>Ok = false</c> with a message. Genuine caller cancellation is likewise converted rather
/// than left to propagate, matching <c>ModbusDriverProbe</c>'s posture (its linked-CTS catch
/// does not distinguish the caller's token from its own deadline).
/// </para>
/// <para>
/// <b>Non-positive <c>timeout</c> (arch-review 01/S-6).</b> A <c>0</c> or negative
/// timeout does NOT mean "wait forever" — it is replaced with <see cref="FallbackTimeout"/>
/// (5s, matching <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>'s own default) so an
/// operator-authorable field can never brick the probe UI.
/// </para>
/// <para>
/// <b>Secrets discipline.</b> <c>AgentUri</c> may carry userinfo credentials
/// (<c>http://user:pass@host/</c>). Every message this type constructs from the URI uses a
/// redacted display form (<see cref="RedactedDisplay"/>) built ONLY from
/// <see cref="Uri.Scheme"/>/<see cref="Uri.Host"/>/<see cref="Uri.Port"/>/<see cref="Uri.AbsolutePath"/>
/// — never <see cref="Uri.UserInfo"/> and never the raw config string — so a password can
/// never reach the AdminUI via the returned <see cref="DriverProbeResult.Message"/>.
/// </para>
/// </remarks>
public sealed class MTConnectDriverProbe : IDriverProbe
{
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Caps an excessive caller <c>timeout</c>. <c>CancellationTokenSource.CancelAfter(TimeSpan)</c>'s
/// legal range tops out near ~49.7 days (<see cref="uint.MaxValue"/> ms minus one); an
/// uncapped pathological value (e.g. a caller passing <c>TimeSpan.FromDays(1000)</c>) throws
/// <see cref="ArgumentOutOfRangeException"/> straight out of
/// <c>CancelAfter</c>, which would violate the "Never throws" contract just as surely as a
/// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
/// defect class, the high end rather than the low end). 10 minutes is generous for a
/// reachability probe and leaves an enormous margin under the legal ceiling.
/// </summary>
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Test seam: routes the reused <see cref="MTConnectAgentClient"/> through a stub handler so response-shape cases need no socket.</summary>
private readonly HttpMessageHandler? _handler;
/// <summary>Creates the probe used in production (real sockets).</summary>
public MTConnectDriverProbe()
: this(handler: null)
{
}
/// <summary>Test seam constructor — see <see cref="_handler"/>.</summary>
internal MTConnectDriverProbe(HttpMessageHandler? handler)
{
_handler = handler;
}
/// <inheritdoc />
/// <remarks>
/// Sourced from the <see cref="DriverTypeNames"/> constant, not a literal — the probe is
/// looked up by <c>AdminOperationsActor</c> under this key, so a drift from the constant
/// silently disables the Test Connect button rather than failing anything at build time.
/// </remarks>
public string DriverType => DriverTypeNames.MTConnect;
/// <summary>Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.</summary>
private sealed class MTConnectProbeConfigDto
{
public string? AgentUri { get; set; }
}
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
MTConnectProbeConfigDto? dto;
try
{
dto = JsonSerializer.Deserialize<MTConnectProbeConfigDto>(configJson, JsonOptions);
}
catch (Exception ex)
{
return new(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (dto is null)
{
return new(false, "Config JSON deserialized to null.", null);
}
var agentUri = dto.AgentUri?.Trim();
if (string.IsNullOrWhiteSpace(agentUri))
{
return new(false, "Config has no agentUri to probe.", null);
}
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
}
var safeUri = RedactedDisplay(parsed);
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a
// pathologically large one must never overrun CancelAfter's legal range (high end) — see
// MaxTimeout.
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
if (effectiveTimeout > MaxTimeout)
{
effectiveTimeout = MaxTimeout;
}
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(effectiveTimeout);
var options = new MTConnectDriverOptions
{
AgentUri = agentUri,
RequestTimeoutMs = requestTimeoutMs,
};
try
{
using var client = new MTConnectAgentClient(options, _handler);
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
sw.Stop();
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
}
catch (OperationCanceledException)
{
// Covers both the caller's token and our own CancelAfter deadline firing — see the type
// remarks on why cancellation is never allowed to propagate.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (TimeoutException)
{
// MTConnectAgentClient's own request-deadline signal.
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
}
catch (HttpRequestException ex)
{
// Connection failure or non-2xx status. HttpRequestException messages describe the
// socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
}
catch (InvalidDataException ex)
{
// MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
// an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
// a document with no devices. Never includes the request URI.
return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
}
catch (ArgumentException)
{
// Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
// method's own Uri.TryCreate above passed but the client's stricter check disagreed.
// Never forward the exception's message verbatim — it can echo the raw (possibly
// credentialed) config string.
return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
}
catch (Exception ex)
{
// Last-resort catch for an exception type none of the above enumerate. Deliberately does
// NOT forward ex.Message: unlike the typed catches above (each individually audited for
// whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
// such audit, so ex.Message is an open door for a credential leak. safeUri + the
// exception's TYPE name is enough for an operator to act on without that risk.
return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
}
}
/// <summary>
/// Builds a display form of <paramref name="uri"/> for use in returned messages, composed
/// ONLY from scheme/host/port/path — never <see cref="Uri.UserInfo"/> — so a credentialed
/// <c>AgentUri</c> can never leak a password into the AdminUI.
/// </summary>
private static string RedactedDisplay(Uri uri)
{
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
}
}
@@ -0,0 +1,193 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Result of an Agent <c>/probe</c> request: the full device hierarchy the Agent exposes.
/// Produced by <see cref="IMTConnectAgentClient.ProbeAsync"/>. This is the neutral shape both
/// the TrakHound-backed implementation and a hand-rolled XML fallback must produce — nothing
/// downstream of <see cref="IMTConnectAgentClient"/> ever sees a TrakHound type or an
/// <c>XElement</c>.
/// </summary>
/// <param name="Devices">Every device the Agent's probe response declares.</param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices);
/// <summary>
/// Common shape shared by <see cref="MTConnectDevice"/> and <see cref="MTConnectComponent"/>:
/// each may directly own data items and/or nest further components, to arbitrary depth. Backs
/// the <see cref="MTConnectComponentTreeExtensions.AllDataItems"/> recursive walk.
/// </summary>
public interface IMTConnectComponentContainer
{
/// <summary>Components nested directly under this device/component (may be empty).</summary>
IReadOnlyList<MTConnectComponent> Components { get; }
/// <summary>Data items declared directly on this device/component (may be empty).</summary>
IReadOnlyList<MTConnectDataItem> DataItems { get; }
}
/// <summary>
/// One MTConnect device from the Agent's probe response — the root of a nested
/// component/data-item tree.
/// </summary>
/// <param name="Id">The device's <c>id</c> attribute.</param>
/// <param name="Name">The device's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Components">Child components declared directly under this device.</param>
/// <param name="DataItems">Data items declared directly on this device (outside any component).</param>
public sealed record MTConnectDevice(
string Id,
string? Name,
IReadOnlyList<MTConnectComponent> Components,
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
/// <summary>
/// One MTConnect component (e.g. <c>Axes</c>, <c>Controller</c>, <c>Path</c>) from the Agent's
/// probe response. Components nest to arbitrary depth — a component may itself contain further
/// child components as well as data items.
/// </summary>
/// <param name="Id">The component's <c>id</c> attribute.</param>
/// <param name="Name">The component's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Components">Child components nested directly under this component.</param>
/// <param name="DataItems">Data items declared directly on this component.</param>
public sealed record MTConnectComponent(
string Id,
string? Name,
IReadOnlyList<MTConnectComponent> Components,
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
/// <summary>
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
/// it carries no observed value; values arrive separately via
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
/// and are correlated back to a data item by <see cref="Id"/>.
/// </summary>
/// <param name="Id">
/// The DataItem's <c>id</c> attribute — globally unique within the Agent's probe response and
/// the correlation key used against <see cref="MTConnectObservation.DataItemId"/>.
/// </param>
/// <param name="Name">The DataItem's <c>name</c> attribute, when the Agent supplies one.</param>
/// <param name="Category">The DataItem's <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
/// <param name="Type">The DataItem's <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).</param>
/// <param name="SubType">The DataItem's <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>), when present.</param>
/// <param name="Units">The DataItem's <c>units</c> attribute (e.g. <c>MILLIMETER</c>, <c>REVOLUTION/MINUTE</c>), when present.</param>
/// <param name="Representation">
/// The DataItem's <c>representation</c> attribute (e.g. <c>TIME_SERIES</c>, <c>DISCRETE</c>);
/// absent means the MTConnect default (<c>VALUE</c>).
/// </param>
/// <param name="SampleCount">
/// The DataItem's <c>sampleCount</c> attribute — element count for a <c>TIME_SERIES</c>
/// representation. Present only on data items that carry one.
/// </param>
public sealed record MTConnectDataItem(
string Id,
string? Name,
string Category,
string Type,
string? SubType,
string? Units,
string? Representation,
int? SampleCount);
/// <summary>
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
/// </summary>
/// <param name="InstanceId">
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
/// whenever the Agent restarts or its underlying device model changes; the driver watches this
/// for change to raise rediscovery rather than trusting a stale observation snapshot.
/// </param>
/// <param name="NextSequence">
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
/// </param>
/// <param name="FirstSequence">
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
/// circular buffer. Load-bearing for sequence-gap detection: a caller that requested
/// <c>from</c> a sequence older than this chunk's <see cref="FirstSequence"/> has fallen out of
/// the Agent's buffer and must re-baseline via <see cref="IMTConnectAgentClient.CurrentAsync"/>.
/// </param>
/// <param name="Observations">
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
/// empty when the chunk carries no new observations (e.g. a heartbeat).
/// </param>
public sealed record MTConnectStreamsResult(
long InstanceId,
long NextSequence,
long FirstSequence,
IReadOnlyList<MTConnectObservation> Observations);
/// <summary>
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
/// <c>/sample</c> stream chunk. Deliberately dumb: <see cref="Value"/> is carried as the raw
/// string the Agent reported (including the literal <c>UNAVAILABLE</c>) with no interpretation
/// applied here — coercing <c>UNAVAILABLE</c> to a bad-quality status and converting the string
/// to the tag's <c>DriverDataType</c> are the observation index's job, not this layer's.
/// </summary>
/// <param name="DataItemId">
/// The reporting DataItem's <c>id</c> — correlates back to <see cref="MTConnectDataItem.Id"/>
/// from the probe model.
/// </param>
/// <param name="Value">
/// The observed value exactly as the Agent reported it, or the literal string
/// <c>"UNAVAILABLE"</c> when the Agent has no current value for the data item. Never
/// pre-parsed into a nullable or an enum at this layer.
/// </param>
/// <param name="TimestampUtc">
/// The observation's Agent-reported timestamp, normalized to UTC (<see cref="DateTime.Kind"/>
/// is always <see cref="DateTimeKind.Utc"/>). Becomes the OPC UA variable's SourceTimestamp.
/// </param>
/// <param name="IsStructured">
/// <c>true</c> when the Agent carried this observation's real content in <b>child elements</b>
/// rather than as text — an MTConnect 2.0 <c>DATA_SET</c> / <c>TABLE</c> observation, whose
/// content is a list of <c>&lt;Entry key="…"&gt;</c> elements.
/// <para>
/// <b>Why the flag exists, and why only the parser can set it.</b> <see cref="Value"/> is
/// the element's concatenated descendant text, so a two-entry data set reads as the single
/// token <c>"12"</c> — keys discarded, values run together. The observation index cannot
/// detect that after the fact: neither this record nor the tag definition carries the
/// DataItem's <c>representation</c>, and no value-shape heuristic can work, because
/// concatenated entries are indistinguishable from a legitimate space-bearing EVENT such as
/// a <c>Message</c> reading <c>"Coolant level low"</c>. The one reliable discriminator —
/// that the observation element had element children — exists only while the XML is still
/// XML.
/// </para>
/// <para>
/// Deliberately a <b>neutral fact about the wire shape</b>, not a status: mapping it to a
/// quality code is the observation index's job (it codes these
/// <c>BadNotSupported</c> rather than publishing concatenated noise as Good). Defaults to
/// <c>false</c>, the shape of every ordinary scalar observation.
/// </para>
/// </param>
public sealed record MTConnectObservation(
string DataItemId,
string Value,
DateTime TimestampUtc,
bool IsStructured = false);
/// <summary>
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
/// </summary>
public static class MTConnectComponentTreeExtensions
{
/// <summary>
/// Enumerates every data item owned by this device or component, plus every data item owned
/// by any component nested beneath it, to arbitrary depth. Order is depth-first: a node's
/// own data items first, then each child component's data items in turn.
/// </summary>
/// <param name="container">The device or component to walk.</param>
public static IEnumerable<MTConnectDataItem> AllDataItems(this IMTConnectComponentContainer container)
{
foreach (var dataItem in container.DataItems)
{
yield return dataItem;
}
foreach (var child in container.Components)
{
foreach (var dataItem in child.AllDataItems())
{
yield return dataItem;
}
}
}
}
@@ -0,0 +1,415 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c>&lt;Unavailable/&gt;</c>
/// element name — as a CONDITION's value on that same exact spelling. It maps to
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
/// EVENT whose value happens to read "Unavailable".
/// </para>
/// <para>
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
/// that boundary on account of observation content. Every unusable observation degrades to a
/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
/// always see <i>when</i> the driver last heard about the tag even when it cannot show
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
/// argument — a programming error, not data.
/// </para>
/// <para>
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
/// </para>
/// <para>
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
/// explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
/// offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
/// would serialize the pump against every read for no semantic gain.
/// </para>
/// <para>
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
/// real content in <c>&lt;Entry key=…&gt;</c> child elements, which reach a text-valued
/// observation concatenated with the keys discarded — a two-entry set reads as the single
/// token <c>"12"</c> — and because the type inference correctly demotes those
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
/// itself (no value heuristic can separate concatenated entries from a legitimate
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
/// <see cref="MTConnectObservation.IsStructured"/>, which
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
/// </para>
/// </remarks>
internal sealed class MTConnectObservationIndex
{
/// <summary>
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
/// the parser already normalizes a CONDITION's <c>&lt;Unavailable/&gt;</c> element name onto
/// this exact spelling, so every category lands on one comparison.
/// </summary>
private const string UnavailableSentinel = "UNAVAILABLE";
private const uint Good = 0x00000000u;
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
private const uint BadNoCommunication = 0x80310000u;
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
private const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
private const uint BadOutOfRange = 0x803C0000u;
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
private const uint BadNotSupported = 0x803D0000u;
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
/// several states reported for one dataItemId <i>within a single document</i> — see
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
/// fault is information and an absent value is not. Case-insensitive so a vendor's casing
/// of a state element still ranks; the parser itself emits the canonical spellings.
/// </summary>
private static readonly FrozenDictionary<string, int> ConditionSeverity =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[UnavailableSentinel] = 0,
["Normal"] = 1,
["Warning"] = 2,
["Fault"] = 3,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Builds an index over the driver instance's authored tags.
/// </summary>
/// <param name="tags">
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
/// legal and means "no authored types": every observation is then indexed as its raw text
/// under <see cref="DriverDataType.String"/>.
/// <para>
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
/// operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
/// construct would turn an authoring slip into a driver that will not start, whereas
/// last-wins is deterministic and costs only that one tag's coercion type.
/// </para>
/// </param>
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
public MTConnectObservationIndex(
IEnumerable<MTConnectTagDefinition>? tags = null,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
foreach (var tag in tags ?? [])
{
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
map[tag.FullName] = tag;
}
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
public int Count => _snapshots.Count;
/// <summary>
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
/// with no authored tag are indexed too — the Agent streams the whole device, so they are
/// normal, and dropping them would make a streaming-but-not-yet-authored data item
/// indistinguishable from one that does not exist.
/// </summary>
/// <param name="result">The parsed streams document. An observation-free result is a legal
/// keep-alive and leaves the index untouched.</param>
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
public void Apply(MTConnectStreamsResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (result.Observations is not { Count: > 0 }) return;
// Duplicates are reconciled on the RAW observations first, so each dataItemId is written
// exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
// — writing every observation and merging snapshots in place — would need a read-modify-write
// per key and could expose a half-merged state to a concurrent reader.
var resolved = new Dictionary<string, MTConnectObservation>(StringComparer.Ordinal);
foreach (var observation in result.Observations)
{
if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
resolved[observation.DataItemId] =
resolved.TryGetValue(observation.DataItemId, out var incumbent)
? Reconcile(incumbent, observation)
: observation;
}
foreach (var (dataItemId, observation) in resolved)
{
_snapshots[dataItemId] = BuildSnapshot(observation);
}
}
/// <summary>
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
/// </summary>
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
/// <returns>
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
/// would tell an operator their correctly-authored tag does not exist.
/// </returns>
public DataValueSnapshot Get(string dataItemId)
{
if (!string.IsNullOrWhiteSpace(dataItemId) &&
_snapshots.TryGetValue(dataItemId, out var snapshot))
{
return snapshot;
}
var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
? BadWaitingForInitialData
: BadNodeIdUnknown;
return new DataValueSnapshot(null, status, null, Now);
}
/// <summary>
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
/// longer exists, and serving it would be worse than serving nothing.
/// </summary>
public void Clear() => _snapshots.Clear();
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
/// <summary>
/// Picks the winner when one dataItemId appears more than once in a single document. A
/// <c>&lt;Condition&gt;</c> container legitimately carries several <b>simultaneously active</b>
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
/// the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
/// Warning purely because of element order. Anything else is last-wins, matching the Agent's
/// ascending-sequence ordering.
/// <para>
/// Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
/// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
/// spanned Applies would latch every fault forever.
/// </para>
/// </summary>
private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
{
if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
{
return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
}
return challenger;
}
private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
{
var serverTimestamp = Now;
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
// No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
// the only way to report absence, so an empty element is not an empty string value.
if (string.IsNullOrWhiteSpace(observation.Value) ||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
{
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
}
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
// Publishing that as a Good string would be noise an operator would trust. Checked before
// the tag lookup because it is a fact about the wire shape, true whether or not the data
// item is authored — and after the sentinel, so an unavailable data set still reports the
// truthful no-comms.
if (observation.IsStructured)
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
}
_tags.TryGetValue(observation.DataItemId, out var tag);
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
// type mismatch, and emphatically not the first element parsed out and served as Good.
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
if (tag is { IsArray: true })
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
}
// An unauthored observation has no declared target type; String is the only coercion that
// cannot fail, and it carries the Agent's text with no interpretation applied.
var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
return status == Good
? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
}
/// <summary>
/// Converts the Agent's text to the tag's authored type. Every parse is
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
/// </summary>
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
private static uint Coerce(string raw, DriverDataType type, out object? value)
{
value = null;
var text = raw.Trim();
switch (type)
{
// Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
// never a sensible authoring for MTConnect, but degrading it to text is harmless where
// rejecting it would break a tag for no protection.
case DriverDataType.String:
case DriverDataType.Reference:
value = raw;
return Good;
case DriverDataType.Boolean:
if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
if (text is "1") { value = true; return Good; }
if (text is "0") { value = false; return Good; }
return BadTypeMismatch;
case DriverDataType.DateTime:
if (DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var moment))
{
value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
return Good;
}
return BadTypeMismatch;
case DriverDataType.Float32:
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
{
value = single;
return Good;
}
return BadTypeMismatch;
case DriverDataType.Float64:
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
{
value = real;
return Good;
}
return BadTypeMismatch;
case DriverDataType.Int16:
if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
{
value = i16;
return Good;
}
break;
case DriverDataType.Int32:
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
{
value = i32;
return Good;
}
break;
case DriverDataType.Int64:
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
{
value = i64;
return Good;
}
break;
case DriverDataType.UInt16:
if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
{
value = u16;
return Good;
}
break;
case DriverDataType.UInt32:
if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
{
value = u32;
return Good;
}
break;
case DriverDataType.UInt64:
if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
{
value = u64;
return Good;
}
break;
default:
// A DriverDataType this driver has no rule for. Text always round-trips, so serving
// it beats failing a tag over an enum member added later.
value = raw;
return Good;
}
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
// operator whether to retype the tag or widen it.
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
: BadTypeMismatch; // not a number at all
}
}
@@ -0,0 +1,189 @@
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
/// so the whole parse is unit-testable against canned fixtures.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled rather than TrakHound.</b> The TrakHound packages Task 0 pinned
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2 — both dropped in Task 7) contain <b>no</b>
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
/// <c>Success = false</c> / "Document Formatter Not found for &quot;xml&quot;" (verified by
/// reflection + live invocation against this repo's own fixture, 2026-07-24). TrakHound also
/// offers no socket-free public parse entry point, which the plan requires.
/// </para>
/// <para>
/// <b>Three shapes of the wire format this parser is built around.</b>
/// (1) A device may declare data items <i>directly</i> on <c>&lt;Device&gt;&lt;DataItems&gt;</c>,
/// outside any component — a walker that only recurses <c>&lt;Components&gt;</c> silently
/// drops them.
/// (2) The children of <c>&lt;Components&gt;</c> are named for the component <i>type</i>
/// (<c>&lt;Controller&gt;</c>, <c>&lt;Path&gt;</c>, <c>&lt;Axes&gt;</c>, …) — there is no
/// literal <c>&lt;Component&gt;</c> element — so <i>every</i> element child of
/// <c>&lt;Components&gt;</c> is a component.
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
/// (match on local name, read attributes unqualified) are shared verbatim with
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
/// </para>
/// <para>
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
/// the Agent's document begins. A <c>/sample</c> chunk, by contrast, is lifted out of a
/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
/// the XML declaration, which <c>XmlReader</c> rejects — hence the trim there and not here.
/// This asymmetry is intentional, not an oversight.
/// </para>
/// <para>
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
/// <see cref="InvalidDataException"/>. It never degrades to an empty-but-successful model:
/// an empty device model that looks successful is precisely the defect class that has bitten
/// this codebase before (#485), and here it would tear the driver's browse tree down to
/// nothing while reporting healthy.
/// </para>
/// </remarks>
internal static class MTConnectProbeParser
{
/// <summary>
/// Bound on component nesting depth. Real device models nest a handful of levels; this only
/// exists so a pathological or hostile document cannot recurse the parser into a
/// process-fatal (uncatchable) stack overflow.
/// </summary>
private const int MaxComponentDepth = 64;
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
private const string Subject = "/probe response";
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
/// <exception cref="InvalidDataException">
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
/// under HTTP 200), or declares no usable device.
/// </exception>
public static MTConnectProbeModel Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
throw new InvalidDataException("MTConnect /probe response was empty; expected an MTConnectDevices XML document.");
}
XDocument document;
try
{
document = XDocument.Parse(xml);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectProbeModel Parse(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
XDocument document;
try
{
document = XDocument.Load(stream);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
private static MTConnectProbeModel Build(XDocument document)
{
var root = document.Root
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
}
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
?? throw new InvalidDataException(
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
if (devices.Count == 0)
{
throw new InvalidDataException(
"MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
}
return new MTConnectProbeModel(devices);
}
private static MTConnectDevice ReadDevice(XElement element) =>
new(
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
MTConnectXml.OptionalAttribute(element, "name"),
ReadComponents(element, depth: 1),
ReadDataItems(element));
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
new(
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
MTConnectXml.OptionalAttribute(element, "name"),
ReadComponents(element, depth + 1),
ReadDataItems(element));
/// <summary>
/// Every element child of this container's <c>&lt;Components&gt;</c> element, whatever it is
/// named — the element name is the component type, not the literal string "Component".
/// </summary>
private static IReadOnlyList<MTConnectComponent> ReadComponents(XElement container, int depth)
{
if (depth > MaxComponentDepth)
{
throw new InvalidDataException(
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
}
return MTConnectXml.ChildrenNamed(container, "Components")
.SelectMany(components => components.Elements())
.Select(element => ReadComponent(element, depth))
.ToList();
}
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
MTConnectXml.ChildrenNamed(container, "DataItems")
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
.Select(ReadDataItem)
.ToList();
private static MTConnectDataItem ReadDataItem(XElement element)
{
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
return new MTConnectDataItem(
id,
MTConnectXml.OptionalAttribute(element, "name"),
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
MTConnectXml.OptionalAttribute(element, "subType"),
MTConnectXml.OptionalAttribute(element, "units"),
MTConnectXml.OptionalAttribute(element, "representation"),
MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
}
}
@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Driver-internal identity of one <c>/sample</c> subscription, matching the sibling drivers'
/// shape (Galaxy's <c>GalaxySubscriptionHandle</c>, the shared <c>PollGroupEngine</c>'s
/// <c>PollSubscriptionHandle</c>): a monotonic per-driver id plus a diagnostic string that
/// carries it into logs.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every handle shares ONE Agent stream.</b> Unlike a polled driver, where each
/// subscription owns a loop, MTConnect's <c>/sample</c> long poll is per-Agent: the driver
/// opens exactly one and fans each chunk out to whichever handles subscribe the reporting
/// DataItem. The handle is therefore purely an identity — it owns no connection, no task,
/// and no cursor — and dropping one only stops the stream when it was the last.
/// </para>
/// <para>
/// A <see langword="record"/> for value equality on the id, so a handle that has round-tripped
/// through the caller still resolves. The id is never reused within a driver instance.
/// </para>
/// </remarks>
/// <param name="SubscriptionId">The monotonic per-driver subscription id.</param>
internal sealed record MTConnectSampleHandle(long SubscriptionId) : ISubscriptionHandle
{
/// <inheritdoc/>
public string DiagnosticId => $"mtconnect-sub-{SubscriptionId}";
}
@@ -0,0 +1,161 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Base type for every way an Agent <c>/sample</c> long poll can stop delivering chunks for a
/// reason that is <b>not</b> the caller cancelling it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
/// an enumerator that ends immediately. Returning normally would make "the data path
/// stopped" indistinguishable from "the data path is idle", which is the
/// quiet-successful-looking-termination shape that produced this repo's #485 defect. Every
/// non-cancellation end is therefore loud, typed, and carries the Agent URI.
/// </para>
/// <para>
/// Catch this base type to handle "the stream is over" uniformly; catch the two derived
/// types to distinguish a <i>transient</i> end (the Agent closed a connection — reconnect)
/// from a <i>configuration</i> error (the Agent will never stream to this request — retrying
/// is pointless until an operator changes something).
/// </para>
/// </remarks>
public abstract class MTConnectStreamException : Exception
{
/// <summary>Creates the exception with no message.</summary>
protected MTConnectStreamException()
{
}
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
protected MTConnectStreamException(string message)
: base(message)
{
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
protected MTConnectStreamException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// <summary>How an Agent <c>/sample</c> stream ended.</summary>
public enum MTConnectStreamEndReason
{
/// <summary>
/// The transport closed mid-stream — the connection dropped, the Agent restarted, or a proxy
/// timed the connection out. Ordinary and transient: reconnect from the last
/// <see cref="MTConnectStreamsResult.NextSequence"/>.
/// </summary>
ConnectionClosed = 0,
/// <summary>
/// The Agent wrote the closing <c>--boundary--</c> delimiter, ending the multipart response
/// deliberately. Usually means the request was bounded (an Agent honouring a <c>count</c>
/// that exhausted, or an administrative stop) rather than a fault. Also reconnectable, but
/// worth distinguishing in logs: a stream that keeps closing cleanly points at the request's
/// parameters, not at the network.
/// </summary>
ClosingBoundary = 1
}
/// <summary>
/// The Agent's <c>/sample</c> stream ended without the caller cancelling it. See
/// <see cref="MTConnectStreamException"/> for why this is not a normal end of enumeration.
/// </summary>
public sealed class MTConnectStreamEndedException : MTConnectStreamException
{
/// <summary>Creates the exception for a stream that ended for <paramref name="reason"/>.</summary>
/// <param name="reason">How the stream ended.</param>
/// <param name="agentUri">The <c>/sample</c> request URI whose stream ended.</param>
/// <param name="chunksDelivered">How many chunks were yielded before the end.</param>
public MTConnectStreamEndedException(MTConnectStreamEndReason reason, string agentUri, long chunksDelivered)
: base($"MTConnect /sample stream from '{agentUri}' ended after {chunksDelivered} chunk(s) without the caller cancelling it ({Describe(reason)}). The stream is contracted to run until cancelled, so this is reported rather than returned; resume from the last chunk's nextSequence.")
{
Reason = reason;
AgentUri = agentUri;
ChunksDelivered = chunksDelivered;
}
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
public MTConnectStreamEndedException(string message)
: base(message)
{
AgentUri = string.Empty;
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
public MTConnectStreamEndedException(string message, Exception innerException)
: base(message, innerException)
{
AgentUri = string.Empty;
}
/// <summary>Creates the exception with no message.</summary>
public MTConnectStreamEndedException()
{
AgentUri = string.Empty;
}
/// <summary>How the stream ended.</summary>
public MTConnectStreamEndReason Reason { get; }
/// <summary>The <c>/sample</c> request URI whose stream ended.</summary>
public string AgentUri { get; }
/// <summary>How many chunks were yielded before the stream ended.</summary>
public long ChunksDelivered { get; }
private static string Describe(MTConnectStreamEndReason reason) =>
reason switch
{
MTConnectStreamEndReason.ClosingBoundary => "the Agent wrote the closing multipart boundary",
_ => "the connection closed mid-stream"
};
}
/// <summary>
/// The Agent answered a <c>/sample</c> request with something that cannot be consumed as a
/// stream at all — a single non-multipart document, or a <c>multipart/*</c> response with no
/// <c>boundary</c> parameter to frame it by.
/// </summary>
/// <remarks>
/// This is a <b>configuration</b> error, not a transient one, and is deliberately typed apart
/// from <see cref="MTConnectStreamEndedException"/>: reconnecting will produce the identical
/// response forever. The usual cause is an endpoint that is not an MTConnect Agent (a reverse
/// proxy, a load balancer's health page, or an Agent fronted by something that buffers and
/// collapses <c>multipart/x-mixed-replace</c>). Reported instead of yielding the one document
/// the Agent did return, because a single snapshot followed by a healthy-looking finished
/// stream is exactly how a dead data path disguises itself as a working one.
/// </remarks>
public sealed class MTConnectStreamNotSupportedException : MTConnectStreamException
{
/// <summary>Creates the exception with a message.</summary>
/// <param name="message">The operator-facing description.</param>
public MTConnectStreamNotSupportedException(string message)
: base(message)
{
}
/// <summary>Creates the exception with a message and an inner cause.</summary>
/// <param name="message">The operator-facing description.</param>
/// <param name="innerException">The underlying cause.</param>
public MTConnectStreamNotSupportedException(string message, Exception innerException)
: base(message, innerException)
{
}
/// <summary>Creates the exception with no message.</summary>
public MTConnectStreamNotSupportedException()
{
}
}
@@ -0,0 +1,240 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Turns an MTConnect Agent <c>MTConnectStreams</c> document — a <c>/current</c> snapshot or one
/// chunk of a <c>/sample</c> multipart stream — into the neutral
/// <see cref="MTConnectStreamsResult"/>. Deliberately a pure, socket-free static so the whole
/// parse is unit-testable against canned fixtures; chunk framing lives in
/// <see cref="MTConnectAgentClient"/> and hands this parser one chunk's XML at a time.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled rather than TrakHound.</b> Same finding as
/// <see cref="MTConnectProbeParser"/>: the packages pinned by Task 0 ship no XML document
/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
/// package references were dropped in Task 7; the driver depends only on the BCL.
/// </para>
/// <para>
/// <b>The Streams document is shaped nothing like the Devices document</b>, and three
/// differences drive this parser's design.
/// </para>
/// <list type="number">
/// <item>
/// <description>
/// <b>The observation element's name is the data item's type in PascalCase</b>
/// (<c>Position</c>, <c>PartCount</c>, <c>Execution</c>) — not the probe's
/// UPPER_SNAKE <c>POSITION</c> / <c>PART_COUNT</c>. The standard defines hundreds of
/// types and vendors add more, so matching a fixed set of element names would drop
/// observations silently. <b>Every</b> element child of <c>&lt;Samples&gt;</c>,
/// <c>&lt;Events&gt;</c> and <c>&lt;Condition&gt;</c> is therefore an observation,
/// keyed by its <c>dataItemId</c> attribute — the only identity the probe model and
/// the streams document actually share.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>A CONDITION observation's value is its ELEMENT NAME, not its text.</b>
/// <c>&lt;Normal/&gt;</c> / <c>&lt;Warning/&gt;</c> / <c>&lt;Fault/&gt;</c> /
/// <c>&lt;Unavailable/&gt;</c> carry the state in the name and are typically empty;
/// where text is present it is the operator-facing message, not the state. Reading
/// the text would report every condition as an empty string.
/// <c>&lt;Unavailable/&gt;</c> is normalized to the literal
/// <see cref="UnavailableSentinel"/> so a no-comms condition lands on exactly the
/// same sentinel as a Sample/Event whose text is <c>UNAVAILABLE</c> — the
/// observation index maps that one token to bad quality.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>Timestamps are normalized to UTC explicitly.</b> MTConnect timestamps are
/// ISO-8601 Zulu, but a bare <c>DateTime.Parse</c> yields <c>Local</c> or
/// <c>Unspecified</c> depending on machine settings — and this value becomes the
/// OPC UA SourceTimestamp, so a wrong <see cref="DateTimeKind"/> shifts every
/// timestamp by the host's UTC offset with nothing to show for it.
/// </description>
/// </item>
/// </list>
/// <para>
/// <b>Failure posture.</b> A malformed document, a non-<c>MTConnectStreams</c> root (an Agent
/// <c>MTConnectError</c> served under HTTP 200 included), an unusable header, or an
/// observation missing its <c>dataItemId</c>/<c>timestamp</c> all throw
/// <see cref="InvalidDataException"/>. A document with a valid header and <i>zero</i>
/// observations, by contrast, is entirely legal — <c>/sample</c> chunks are deltas and an
/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
/// observation list rather than throwing.
/// </para>
/// </remarks>
internal static class MTConnectStreamsParser
{
/// <summary>
/// The single token that means "the Agent has no value for this data item". Sample/Event
/// observations carry it as text; a CONDITION carries it as the element name
/// <c>&lt;Unavailable/&gt;</c> and is normalized onto this spelling here.
/// </summary>
private const string UnavailableSentinel = "UNAVAILABLE";
private const string ConditionContainer = "Condition";
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
private const string Subject = "streams response";
/// <summary>How those messages name the header element.</summary>
private const string HeaderOwner = "the <Header>";
/// <summary>
/// The three elements whose element children are observations. A ComponentStream may carry
/// any subset of them, including none.
/// </summary>
private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];
/// <summary>Parses a <c>/current</c> response, or one <c>/sample</c> chunk, held as a string.</summary>
/// <param name="xml">The raw <c>MTConnectStreams</c> XML document.</param>
/// <exception cref="InvalidDataException">
/// The payload is empty, not well-formed XML, not an <c>MTConnectStreams</c> document (an
/// Agent <c>MTConnectError</c> included — Agents answer a bad request with one under HTTP
/// 200), carries no usable <c>&lt;Header&gt;</c>, or carries a malformed observation.
/// </exception>
public static MTConnectStreamsResult Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
XDocument document;
try
{
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
// trailing line break; XmlReader rejects either ahead of the XML declaration.
document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectStreamsResult Parse(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
XDocument document;
try
{
document = XDocument.Load(stream);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
}
return Build(document);
}
private static MTConnectStreamsResult Build(XDocument document)
{
var root = document.Root
?? throw new InvalidDataException("MTConnect streams response has no root element.");
if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
}
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
?? throw new InvalidDataException(
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
return new MTConnectStreamsResult(
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
ReadObservations(root));
}
/// <summary>
/// Collects every observation in the document, in Agent-supplied order. Containers are found
/// by descending the whole <c>&lt;Streams&gt;</c> subtree rather than by walking a fixed
/// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
/// hide a device's observations.
/// </summary>
private static IReadOnlyList<MTConnectObservation> ReadObservations(XElement root)
{
var observations = new List<MTConnectObservation>();
foreach (var container in root.Descendants().Where(IsObservationContainer))
{
var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
foreach (var element in container.Elements())
{
observations.Add(ReadObservation(element, isCondition));
}
}
return observations;
}
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
{
var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
return new MTConnectObservation(
dataItemId,
isCondition ? ConditionState(element) : element.Value.Trim(),
ReadTimestampUtc(element, dataItemId),
// A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
// because this is the only layer that can still see the distinction — see
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
// the element NAME, so child content cannot corrupt it, and flagging one would throw
// away a perfectly well-determined state.
IsStructured: !isCondition && element.HasElements);
}
/// <summary>
/// A condition's state is its element name. <c>Unavailable</c> is normalized to the
/// <see cref="UnavailableSentinel"/> spelling so downstream quality mapping has exactly one
/// token to recognize; every other state is reported as-named (<c>Normal</c>, <c>Warning</c>,
/// <c>Fault</c>, and any vendor state).
/// </summary>
private static string ConditionState(XElement element)
{
var state = element.Name.LocalName;
return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
}
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
{
var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
if (!DateTime.TryParse(
raw,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var parsed))
{
throw new InvalidDataException(
$"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
}
// AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
// quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
private static bool IsObservationContainer(XElement element) =>
ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
}

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