Compare commits

...

10 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
112 changed files with 6065 additions and 4741 deletions
+71 -6
View File
@@ -153,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
@@ -269,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".
@@ -380,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
@@ -490,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.
@@ -499,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
@@ -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
],
"lastUpdated": "2026-07-12"
"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-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.*
+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**
+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`) |
+17 -12
View File
@@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a
**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` — see [Known limitations](#known-limitations) for why
that signal currently has no consumer.
`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
@@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build).
These are real, not placeholders — read them before relying on the driver
for anything beyond values-and-conditions.
1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the
server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and
`IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to
`OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver`
wiring its own internal `DeployWatcher` sub-component. So a restarted
Agent (a changed `instanceId`) leaves a stale address space behind an
otherwise-Healthy driver. **This is a pre-existing fleet-wide gap
affecting every driver that implements either interface** (eight drivers
besides Galaxy), not something specific to MTConnect — this build simply
surfaced it again.
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
+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
+9 -3
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,14 +15,18 @@ 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 |
@@ -31,6 +35,8 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
| [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
+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)."
}
@@ -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)."
}
@@ -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,8 +30,8 @@ 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 |
| **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` |
@@ -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,7 +139,7 @@ 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.
@@ -242,9 +251,10 @@ Both CI-simulatable on the shared docker host, no hardware.
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. **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).
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).
@@ -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
],
"lastUpdated": "2026-07-24"
"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-27",
"closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
}
@@ -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).
@@ -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.
@@ -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
@@ -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;
}
@@ -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)
{
@@ -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>
@@ -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);
@@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </remarks>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The DataItem ids the authored raw tags bind. <c>DiscoverAsync</c> emits
/// <c>FullName: dataItem.Id</c> from the Agent's probe model, so the two sets are the same
/// vocabulary.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
/// <inheritdoc />
/// <remarks>False — <c>DiscoverAsync</c> is built purely from the Agent's probe model and never
/// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
/// missing. MTConnect is currently the only driver where both drift directions are detectable.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => false;
/// <inheritdoc/>
/// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
@@ -23,7 +23,9 @@ public sealed class ModbusDriver
{
// ---- instance fields (grouped at top for auditability) ----
private readonly ModbusDriverOptions _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/transport uses it.</summary>
private ModbusDriverOptions _options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
private readonly string _driverInstanceId;
private readonly ILogger<ModbusDriver> _logger;
@@ -188,12 +190,29 @@ public sealed class ModbusDriver
/// <inheritdoc />
public string DriverType => "Modbus";
/// <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 async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(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. An empty /
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
@@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
return new ModbusDriver(
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
transportFactory: null,
logger: loggerFactory?.CreateLogger<ModbusDriver>());
}
/// <summary>
/// Parses a Modbus <c>DriverConfig</c> JSON document into typed options. Extracted from
/// <see cref="CreateInstance(string,string,ILoggerFactory?)"/> so <see cref="ModbusDriver.InitializeAsync"/>
/// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was
/// constructed with — which silently discarded every edit while the deployment still sealed green.
/// </summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
/// <returns>The parsed <see cref="ModbusDriverOptions"/>.</returns>
internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions
},
};
return new ModbusDriver(
options, driverInstanceId,
transportFactory: null,
logger: loggerFactory?.CreateLogger<ModbusDriver>());
return options;
}
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
@@ -54,7 +54,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
}
private readonly OpcUaClientDriverOptions _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 OpcUaClientDriverOptions _options;
private readonly ISecretResolver _secretResolver;
private readonly string _driverInstanceId;
// ---- IAlarmSource state ----
@@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc />
public string DriverType => "OpcUaClient";
/// <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 async 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 — the endpoint, security policy and tag set were all frozen at construction, and only
// secret rotation was picked up.
if (HasConfigBody(driverConfigJson))
_options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
// not as a runtime surprise.
@@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions
public static OpcUaClientDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
ISecretResolver? secretResolver = null)
{
return new OpcUaClientDriver(
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
loggerFactory?.CreateLogger<OpcUaClientDriver>(),
secretResolver ?? NullSecretResolver.Instance);
}
/// <summary>
/// Parses an OpcUaClient <c>DriverConfig</c> JSON document into typed options. Extracted so
/// <see cref="OpcUaClientDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
/// (Gitea #516) instead of serving the options it was constructed with. Note the driver's own
/// doc-comment previously claimed "resolving on every InitializeAsync picks up rotations" — that was
/// true of SECRET rotation only; the endpoint, security policy and tag set were all frozen at
/// construction.
/// </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="OpcUaClientDriverOptions"/>.</returns>
internal static OpcUaClientDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
return new OpcUaClientDriver(
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
secretResolver ?? NullSecretResolver.Instance);
}
}
@@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession
Name: column.Name,
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
IsArray: false,
SecurityClass: ReadOnlySecurityClass),
SecurityClass: ReadOnlySecurityClass,
IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
];
}
@@ -1,4 +1,5 @@
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -51,16 +52,19 @@ public sealed class SqlDriver
// ---- instance fields (grouped at top for auditability) ----
private readonly SqlDriverOptions _options;
/// <summary>Config-derived, so mutable — see <see cref="ApplyBinding"/>.</summary>
private SqlDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ISqlDialect _dialect;
private readonly DbProviderFactory _factory;
/// <summary>Config-derived, so mutable: <see cref="ApplyBinding"/> swaps it with the connection string
/// and options together on a reinitialize (#516).</summary>
private ISqlDialect _dialect;
private DbProviderFactory _factory;
/// <summary>
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
/// </summary>
private readonly string _connectionString;
private string _connectionString;
private readonly ILogger<SqlDriver> _logger;
@@ -93,11 +97,19 @@ public sealed class SqlDriver
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary>The factory the CALLER passed, or null to take the dialect's. Kept separate from
/// <see cref="_factory"/> so a re-derived dialect cannot silently displace a test's injected factory.</summary>
private readonly DbProviderFactory? _explicitFactory;
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
/// was constructed directly, which keeps the constructor-supplied trio authoritative.</summary>
private readonly Func<string, SqlDriverBinding>? _rebind;
/// <summary>
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
/// delegate must read this driver's live authored table, which does not exist before the driver does.
/// </summary>
private readonly SqlPollReader _reader;
private SqlPollReader _reader;
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
private readonly PollGroupEngine _poll;
@@ -133,6 +145,11 @@ public sealed class SqlDriver
/// explicitly by tests that need to observe or delay connection creation.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</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 —
/// every test) keeps the constructor-supplied options, dialect and connection string.
/// </param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
public SqlDriver(
@@ -141,7 +158,8 @@ public sealed class SqlDriver
ISqlDialect dialect,
string connectionString,
DbProviderFactory? factory = null,
ILogger<SqlDriver>? logger = null)
ILogger<SqlDriver>? logger = null,
Func<string, SqlDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(dialect);
@@ -149,15 +167,47 @@ public sealed class SqlDriver
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
_options = options;
_driverInstanceId = driverInstanceId;
_dialect = dialect;
_factory = factory ?? dialect.Factory;
_connectionString = connectionString;
_logger = logger ?? NullLogger<SqlDriver>.Instance;
Endpoint = DescribeEndpoint(connectionString);
_explicitFactory = factory;
_rebind = rebind;
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
// Everything below this line is derived from config and is re-derived wholesale on a reinitialize.
ApplyBinding(options, dialect, connectionString);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <summary>
/// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything
/// downstream of it (the provider factory, the endpoint description, and the poll reader, which
/// captures all three). Called from the constructor and again from <see cref="InitializeAsync"/>
/// when a changed config arrives.
/// <para><b>All-or-nothing on purpose.</b> The reason Sql was excluded from the first #516 pass is
/// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing
/// it in one method means a future field derived from config gets added here rather than being
/// forgotten on the reinit path.</para>
/// <para>Not thread-safe by design: only the init path calls it, before any poll group is running.
/// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to
/// <c>ReadAsync</c> and the resolver reads the live tag table, so neither captures the old trio.</para>
/// </summary>
/// <param name="options">The typed options to adopt.</param>
/// <param name="dialect">The provider seam to adopt.</param>
/// <param name="connectionString">The resolved connection string to adopt. Never logged.</param>
[MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))]
private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString)
{
_options = options;
_dialect = dialect;
_factory = _explicitFactory ?? dialect.Factory;
_connectionString = connectionString;
Endpoint = DescribeEndpoint(connectionString);
_reader = new SqlPollReader(
_factory,
connectionString,
@@ -168,12 +218,16 @@ public sealed class SqlDriver
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
logger: _logger);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <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 trio.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
@@ -187,7 +241,7 @@ public sealed class SqlDriver
/// the connection string names both. This is the only rendering of the connection string that may
/// appear in a log, a health message, or the Admin UI.
/// </summary>
public string Endpoint { get; }
public string Endpoint { get; private set; } = "";
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
public string HostName => Endpoint;
@@ -215,9 +269,20 @@ public sealed class SqlDriver
/// <summary>
/// Builds the authored RawPath table and proves the database is reachable.
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise
/// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when
/// written, because the host reinitialized the EXISTING child in place and every config edit was
/// silently discarded while the deployment still sealed green.</para>
/// <para>The re-parse is <b>all-or-nothing</b> via <see cref="ApplyBinding"/>: options, the
/// <see cref="ISqlDialect"/> and the resolved connection string are re-derived by one
/// <c>ParseBinding</c> call and adopted together. That is what makes it safe here — adopting options
/// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded
/// from the first #516 pass. It happens BEFORE <c>BuildTagTable</c>, so the tag table and the
/// connection it is polled over always come from the same revision.</para>
/// <para>A driver constructed directly (every unit test) gets no rebinder and keeps its
/// constructor-supplied trio, so <c>"{}"</c>-passing lifecycle tests are unaffected.
/// <c>DriverSpawnPlanner</c>'s stop + respawn remains the outer guarantee.</para>
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
@@ -235,6 +300,18 @@ public sealed class SqlDriver
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
// #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
}
BuildTagTable();
try
@@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>
/// Parses a Sql <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
/// from config: the typed options, the <see cref="ISqlDialect"/> the <c>provider</c> key selects, and
/// the connection string resolved from <c>connectionStringRef</c>.
/// <para>Returned together because they must be adopted together. Re-parsing options alone on a
/// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a
/// half-applied config change is worse than a discarded one, because it looks like it worked.</para>
/// <para>Re-running this on reinitialize also re-resolves the connection string from its environment
/// variable, so a rotated credential is picked up without a process restart.</para>
/// </summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <returns>The parsed options, dialect and resolved connection string.</returns>
/// <exception cref="InvalidOperationException">The config is malformed or names no connection string.</exception>
internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
// that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
}
/// <summary>
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
@@ -102,25 +139,11 @@ public static class SqlDriverFactoryExtensions
public static SqlDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = Deserialize(driverInstanceId, driverConfigJson);
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
{
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
}
var dialect = CreateDialect(dto.Provider, driverInstanceId);
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
// secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
var options = BuildOptions(dto, driverInstanceId);
var binding = ParseBinding(driverInstanceId, driverConfigJson);
var dto = binding.Dto;
var dialect = binding.Dialect;
var connectionString = binding.ConnectionString;
var options = binding.Options;
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
@@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions
dialect,
connectionString,
factory: null,
logger: loggerFactory?.CreateLogger<SqlDriver>());
logger: loggerFactory?.CreateLogger<SqlDriver>(),
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options,
// dialect and connection string together on a reinitialize (#516). Re-resolving the connection
// string also picks up a rotated credential without a process restart.
rebind: json => ParseBinding(driverInstanceId, json));
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
// permitted outside the provider call.
@@ -250,3 +277,21 @@ public static class SqlDriverFactoryExtensions
}
}
}
/// <summary>
/// Everything <c>SqlDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// <para>Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
/// connection string are both config-derived, so adopting new <see cref="Options"/> alone would poll a
/// NEW tag set against the OLD database (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
public sealed record SqlDriverBinding(
SqlDriverOptions Options,
ISqlDialect Dialect,
string ConnectionString,
SqlDriverConfigDto Dto);
@@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> is its RawPath and its driver FullName
/// (<c>DiscoverAsync</c> emits <c>FullName: tag.Name</c>), directly comparable to the browsed
/// symbols' <c>InstancePath</c>.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed ADS symbols AND re-emits every authored tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -28,6 +28,19 @@ else if (!IsNew && _existing is null)
}
else
{
<div class="alert alert-warning" role="alert">
<strong>This grant will not be enforced.</strong>
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
ACL rows — the permission evaluator has no production call site. Nothing you set below will
change what a client can read, write, browse or acknowledge.
<br />
Real access control today is fleet-wide LDAP-group → role mapping
(<span class="mono">Security:Ldap:GroupToRole</span>):
<span class="mono">WriteOperate</span> to write any tag,
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
See <span class="mono">docs/security.md</span>.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
@@ -19,6 +19,19 @@
}
else
{
<div class="alert alert-warning" role="alert">
<strong>These rules are not enforced.</strong>
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
evaluates them — the permission evaluator has no production call site. Authoring a grant here
changes nothing about what a client can read or write.
<br />
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
<span class="mono">WriteOperate</span> role to write any tag and
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
</div>
<section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
@@ -191,6 +191,16 @@ else
{
<span class="mono small">@d.DriverInstanceId</span>
}
@if (d.RediscoveryNeededUtc is not null)
{
@* The driver reported its remote's tag set may have changed. Advisory
only — v3 authors raw tags via /raw browse-commit, so nothing is
grafted at runtime and an operator must re-browse to pick it up. *@
<span class="chip chip-warn ms-1"
title="@($"Reported {d.RediscoveryNeededUtc:u}: {d.RediscoveryReason ?? "tag set may have changed"}. The served address space is unchanged — re-browse this device under /raw and commit anything new.")">
re-browse
</span>
}
</td>
<td>@(d.DriverType ?? "—")</td>
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
@@ -39,38 +39,23 @@
</div>
</div>
@switch (_driverType)
@* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and
not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately
have no per-device form; the parity test consults that set. *@
@if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType)
{
<DynamicComponent Type="deviceFormType" Parameters="_deviceFormParameters" />
}
else if (DeviceFormMap.IsSingleConnection(_driverType))
{
<div class="alert alert-info">
This driver holds a single connection, authored on the <strong>driver</strong>.
There is nothing to configure per device.
</div>
}
else
{
case DriverTypeNames.Modbus:
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.S7:
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbCip:
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.FOCAS:
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<div class="mt-3">
@@ -120,6 +105,14 @@
private string _name = "";
private bool _enabled = true;
private string _deviceConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render —
/// see DriverConfigModal for why this must not be cached.</summary>
private Dictionary<string, object> _deviceFormParameters => new()
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
};
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
@@ -31,50 +31,25 @@
</div>
</div>
@switch (_driverType)
@* Rendered from DriverConfigFormMap rather than a @switch: a Razor switch compiles
into BuildRenderTree's IL, so no test can enumerate its cases — which is how this
modal came to be missing a Calculation arm while the driver-picker guard stayed
green (deferment.md G-1/G-4). The map is guarded by DriverFormMapParityTests. *@
@if (DriverConfigFormMap.Resolve(_driverType) is { } formType)
{
<DynamicComponent Type="formType" Parameters="_formParameters" />
}
else
{
case DriverTypeNames.Modbus:
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.S7:
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbCip:
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.FOCAS:
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.MTConnect:
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
their device forms are informational. Every other driver splits the endpoint onto
the device (the v3 endpoint→DeviceConfig split). *@
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
@* Some drivers hold ONE connection per driver instance and author it here; the rest
split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is
DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and
so told Sql and MTConnect authors to go find an endpoint field on a device form that
does not exist (deferment.md G-3). *@
@if (DeviceFormMap.IsSingleConnection(_driverType))
{
<p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its
@@ -133,6 +108,17 @@
private string _driverType = "";
private string _name = "";
private string _driverConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so
/// the child receives the CURRENT json — a cached dictionary would pin the value captured at first
/// render and silently strand every subsequent edit.</summary>
private Dictionary<string, object> _formParameters => new()
{
["DriverConfigJson"] = _driverConfigJson,
["DriverConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
["ResilienceConfig"] = _resilienceConfig!,
["ResilienceConfigChanged"] = EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
};
private string? _resilienceConfig;
private byte[] _rowVersion = [];
@@ -0,0 +1,105 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// <c>DriverType → typed driver-config form component</c>, rendered by <c>DriverConfigModal</c> through
/// <c>&lt;DynamicComponent&gt;</c>.
/// <para><b>Why this is a map and not the <c>@switch</c> it replaced.</b> A Razor <c>@switch</c> compiles
/// into <c>BuildRenderTree</c>'s IL, so no test can enumerate its cases — which is exactly how the modal
/// came to be missing a <c>Calculation</c> arm while the sibling driver-picker guard stayed green
/// (<c>deferment.md</c> G-1/G-4). The picker test only works because <c>RawDriverTypeDialog</c> keeps its
/// data in a field the markup enumerates; this type gives the two config modals the same property, and
/// being <c>public</c> it needs none of that test's <c>BindingFlags.NonPublic</c> fragility.</para>
/// <para>Modelled on <c>TagConfigEditorMap</c>. Guarded by <c>DriverFormMapParityTests</c>.</para>
/// </summary>
public static class DriverConfigFormMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Modbus] = typeof(ModbusDriverForm),
[DriverTypeNames.S7] = typeof(S7DriverForm),
[DriverTypeNames.AbCip] = typeof(AbCipDriverForm),
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDriverForm),
[DriverTypeNames.TwinCAT] = typeof(TwinCATDriverForm),
[DriverTypeNames.FOCAS] = typeof(FocasDriverForm),
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDriverForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDriverForm),
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
};
/// <summary>The driver types that have a typed config form.</summary>
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
/// <summary>Resolves the form component for a driver type, or null when none is mapped (the modal
/// then renders its "no typed config form" warning rather than crashing).</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns>The form component type, or <see langword="null"/>.</returns>
public static Type? Resolve(string? driverType) =>
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
/// <summary>
/// <c>DriverType → typed device-config form component</c>, rendered by <c>DeviceModal</c>.
/// <para><b>Not every driver has one, by design.</b> The types in
/// <see cref="SingleConnectionDriverTypes"/> hold ONE connection per driver instance and author it on the
/// driver form, so a per-device endpoint editor would be meaningless. The parity test consults that set
/// rather than demanding total coverage — which keeps it a real guard instead of one that has to be
/// suppressed.</para>
/// </summary>
public static class DeviceFormMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Modbus] = typeof(ModbusDeviceForm),
[DriverTypeNames.S7] = typeof(S7DeviceForm),
[DriverTypeNames.AbCip] = typeof(AbCipDeviceForm),
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDeviceForm),
[DriverTypeNames.TwinCAT] = typeof(TwinCATDeviceForm),
[DriverTypeNames.FOCAS] = typeof(FocasDeviceForm),
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
};
/// <summary>
/// Driver types that hold a single connection at the DRIVER level, so they legitimately have no
/// per-device form. Galaxy and Mqtt author a connection on the driver form and keep an informational
/// device form; Sql (one connection string), MTConnect (one Agent URI) and Calculation (no
/// connection at all) have no device form to render.
/// <para>Read by <c>DriverConfigModal</c> so the operator is told where the endpoint actually lives —
/// it previously hardcoded Galaxy-or-Mqtt and told Sql/MTConnect authors to go and find an endpoint
/// field on the device that does not exist (<c>deferment.md</c> G-3).</para>
/// </summary>
public static readonly IReadOnlySet<string> SingleConnectionDriverTypes =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
DriverTypeNames.Galaxy,
DriverTypeNames.Mqtt,
DriverTypeNames.Sql,
DriverTypeNames.MTConnect,
DriverTypeNames.Calculation,
};
/// <summary>The driver types that have a typed device form.</summary>
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
/// <summary>Resolves the device form component for a driver type, or null when none is mapped.</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns>The form component type, or <see langword="null"/>.</returns>
public static Type? Resolve(string? driverType) =>
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
/// <summary>True when this driver authors its connection on the DRIVER form rather than per-device.</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns><see langword="true"/> when the connection is driver-level.</returns>
public static bool IsSingleConnection(string? driverType) =>
driverType is not null && SingleConnectionDriverTypes.Contains(driverType);
}
@@ -0,0 +1,103 @@
@* Embeddable Calculation pseudo-driver config form body. The Calculation driver has NO connection of any
kind — it computes signal-level tags from other tags' RawPaths — so its only driver-level knob is the
per-run script timeout. rawTags is composer-owned and never authored here.
Closes deferment.md G-1: Calculation was offered in the /raw driver picker and registered by the factory,
but DriverConfigModal had no arm for it, so it fell through to the "No typed config form" warning and
RunTimeout was unauthorable through the UI. Hosted by DriverConfigModal via DriverConfigFormMap. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Evaluation</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Run timeout (ms)</label>
<InputNumber @bind-Value="_form.RunTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">
Per-evaluation deadline for a calculated tag's script. Blank = the driver default (2000 ms).
A script exceeding it is abandoned and the tag publishes Bad.
</div>
</div>
</div>
<p class="form-text mt-3 mb-0">
This driver has no connection to author — it reads other tags by RawPath. Its calculated tags and
their scripts are authored on the tags themselves under <strong>/raw</strong>.
</p>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON.</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// camelCase + omit-nulls, matching the driver's case-insensitive deserialize. Omitting runTimeoutMs
// means "use the driver default" rather than pinning the current default into the blob.
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed, so an in-progress edit is not clobbered.
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_form = new FormModel { RunTimeoutMs = TryDeserialize(DriverConfigJson)?.RunTimeoutMs };
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current config to camelCase JSON. rawTags is never emitted — the composer
/// adds it at deploy time.</summary>
public string GetConfigJson() => JsonSerializer.Serialize(new Dto { RunTimeoutMs = _form.RunTimeoutMs }, _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static Dto? TryDeserialize(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<Dto>(json, _jsonOpts); }
catch (JsonException) { return null; } // a hand-edited blob must not crash the modal
}
/// <summary>Mirrors the driver's own config DTO. rawTags is deliberately absent — it is composer-owned,
/// and round-tripping it through this form would let the editor drop tags on save.</summary>
private sealed class Dto
{
public int? RunTimeoutMs { get; init; }
}
private sealed class FormModel
{
public int? RunTimeoutMs { get; set; }
}
}
@@ -1,7 +1,6 @@
@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller
creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via
the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other
/raw modal shells. *@
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@@ -56,8 +55,8 @@
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is
// authorable now though its factory arrives in Wave C.
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against
// DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection.
private static readonly (string Label, string Value)[] Types =
[
("Modbus", DriverTypeNames.Modbus),
@@ -35,9 +35,15 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// <param name="LastError">Latest error message; null when none.</param>
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
/// <param name="PublishedUtc">Timestamp the snapshot was published.</param>
/// <param name="RediscoveryNeededUtc">When the driver last reported that the remote's tag set may have
/// changed; null when never (or when the driver cannot report it). Advisory — the served address space
/// is unchanged and an operator must re-browse the device via <c>/raw</c> to pick anything up.</param>
/// <param name="RediscoveryReason">The driver-supplied reason for that report; null when
/// <paramref name="RediscoveryNeededUtc"/> is null.</param>
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null);
/// <summary>
/// One cluster's section on the <c>/hosts</c> page: its configured nodes plus its enriched
@@ -110,7 +116,9 @@ public static class HostsDriverView
s.LastSuccessfulReadUtc,
s.LastError,
s.ErrorCount5Min,
s.PublishedUtc);
s.PublishedUtc,
s.RediscoveryNeededUtc,
s.RediscoveryReason);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper
return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
// Sql editor does not read: it would open with empty fields and blank them on save — the exact
// failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable
// (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue.
if (Is(driverType, DriverTypeNames.Sql))
return BuildSqlTagConfig(address, addressFields);
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
// "address" key so nothing is lost. Browsable drivers are all handled above.
// "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
// this comment.
return WriteSingleKey("address", address);
}
@@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper
private static bool Is(string driverType, string name)
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Builds a <c>SqlTagConfigModel</c> blob from a browsed COLUMN leaf. Maps to
/// <c>SqlTagModel.WideRow</c> — the browse tree is schema → table → column, which is precisely the
/// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived
/// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the
/// typed editor.
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
/// </summary>
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
{
if (addressFields is null
|| !addressFields.TryGetValue("table", out var table)
|| string.IsNullOrWhiteSpace(table))
{
return WriteSingleKey("address", address);
}
addressFields.TryGetValue("schema", out var schema);
addressFields.TryGetValue("columnName", out var columnName);
// Qualify the table with its schema when the schema is not the default, so a tag authored against
// "sales.Readings" cannot silently resolve to "dbo.Readings".
var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase)
? $"{schema}.{table}"
: table;
return new SqlTagConfigModel
{
Model = SqlTagModel.WideRow,
Table = qualified,
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
}.ToJson();
}
private static string WriteSingleKey(string key, string value)
{
var o = new JsonObject { [key] = value };
@@ -319,9 +319,11 @@ public static class CsvColumnMap
public static IReadOnlyList<string> CommonColumns { get; } =
[.. CommonLeadingColumns, TagConfigJsonColumn];
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
public const string CalculationDriverType = "Calculation";
/// <summary>The Calculation (virtual-tag) driver-type string.</summary>
/// <remarks>Retained as an alias for <see cref="DriverTypeNames.Calculation"/>, which now exists — the
/// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2
/// package") went stale once the factory registered.</remarks>
public const string CalculationDriverType = DriverTypeNames.Calculation;
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
@@ -449,6 +451,46 @@ public static class CsvColumnMap
new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
}),
// deferment.md G-5 — Sql, Mqtt and MTConnect all have typed tag editors but had NO CSV map, so
// import/export silently degraded them to the raw TagConfigJson fallback while the seven older
// drivers got typed columns. Raw-key maps (as Galaxy and Calculation use) rather than
// model-backed ones: they address the identity keys directly, which is what a CSV round-trip
// needs, without duplicating each model's full accessor surface.
new RawKeyCsvDriverTagMap(DriverTypeNames.Sql, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("Model", "model", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("Table", "table", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("KeyColumn", "keyColumn", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("KeyValue", "keyValue", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("ValueColumn", "valueColumn", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("ColumnName", "columnName", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("SqlType", "type", null), CsvRawKeyKind.String),
}),
new RawKeyCsvDriverTagMap(DriverTypeNames.Mqtt, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("Mode", "mode", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("Topic", "topic", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("PayloadFormat", "payloadFormat", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("JsonPath", "jsonPath", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MqttDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("GroupId", "groupId", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("EdgeNodeId", "edgeNodeId", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String),
}),
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtUnits", "units", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
}),
};
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
@@ -22,9 +22,9 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
// the factory" — went stale when the constant landed; the key is correct either way.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
@@ -118,7 +118,9 @@ public static class TelemetryProtoMapCentral
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
LastError: msg.HasLastError ? msg.LastError : null,
ErrorCount5Min: msg.ErrorCount5Min,
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"));
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"),
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null);
}
/// <summary>Projects a <see cref="DriverResilienceStatus"/> onto a <see cref="DriverResilienceStatusChanged"/>.</summary>
@@ -128,6 +128,10 @@ public static class TelemetryProtoMapNode
msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value);
if (e.LastError is not null)
msg.LastError = e.LastError;
if (e.RediscoveryNeededUtc is not null)
msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value);
if (e.RediscoveryReason is not null)
msg.RediscoveryReason = e.RediscoveryReason;
return msg;
}
@@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier
return failed;
}
/// <summary>
/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
/// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only
/// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass.
/// </summary>
/// <param name="equipmentRootNodeId">The equipment root node the discovered nodes hang under; the
/// NodeAdded model-change is announced under this node.</param>
/// <param name="folders">The discovered folders to ensure (parent-first by depth).</param>
/// <param name="variables">The discovered variables to ensure (read-only value nodes).</param>
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
/// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1).</returns>
public int MaterialiseDiscoveredNodes(
string equipmentRootNodeId,
IReadOnlyList<DiscoveredFolder> folders,
IReadOnlyList<DiscoveredVariable> variables)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId);
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(variables);
if (folders.Count == 0 && variables.Count == 0) return 0;
var failed = 0;
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
foreach (var v in variables)
{
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
var writable = v.Writable && !v.IsArray;
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
}
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
_logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
equipmentRootNodeId, folders.Count, variables.Count, failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
@@ -1,8 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>A folder to ensure during discovered-node injection (NodeId + parent + display).</summary>
public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);
/// <summary>A read-or-write variable to ensure during discovered-node injection.</summary>
public sealed record DiscoveredVariable(
string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
@@ -30,7 +30,13 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
}
/// <inheritdoc />
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null)
{
var msg = new DriverHealthChanged(
clusterId,
@@ -39,7 +45,9 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
health.LastSuccessfulRead,
health.LastError,
errorCount5Min,
DateTime.UtcNow);
DateTime.UtcNow,
rediscoveryNeededUtc,
rediscoveryReason);
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
@@ -1,71 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
/// (discovered alarms are out of scope — alarms come via the config path).</para>
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
private readonly List<DiscoveredNode> _nodes;
private readonly IReadOnlyList<string> _path;
/// <summary>Create a root capturing builder with an empty folder path and a fresh node list.</summary>
public CapturingAddressSpaceBuilder() : this([], []) { }
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
{
_nodes = nodes;
_path = path;
}
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
_nodes.Add(new DiscoveredNode(
FolderPathSegments: _path,
BrowseName: browseName,
DisplayName: displayName,
FullReference: attributeInfo.FullName,
DataType: attributeInfo.DriverDataType,
IsArray: attributeInfo.IsArray,
ArrayDim: attributeInfo.ArrayDim,
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
IsHistorized: attributeInfo.IsHistorized));
return new NullHandle(attributeInfo.FullName);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
/// <summary>A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).</summary>
private sealed class NullHandle(string fullRef) : IVariableHandle
{
/// <inheritdoc />
public string FullReference => fullRef;
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>A null sink that ignores alarm condition transitions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -1,19 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
/// </summary>
public sealed record DiscoveredNode(
IReadOnlyList<string> FolderPathSegments,
string BrowseName,
string DisplayName,
string FullReference,
DriverDataType DataType,
bool IsArray,
uint? ArrayDim,
bool Writable,
bool IsHistorized);
@@ -1,118 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>The mapped result of grafting discovered nodes under an equipment node.</summary>
/// <param name="Folders">
/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
/// global parent-before-child ordering across the whole list.
/// </param>
/// <param name="Variables">Variables to ensure under the (post-collapse) folders.</param>
/// <param name="RoutingByRef">Driver FullReference -> equipment NodeId, for live-value routing.</param>
public sealed record DiscoveredInjectionPlan(
IReadOnlyList<DiscoveredFolder> Folders,
IReadOnlyList<DiscoveredVariable> Variables,
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference -> equipment NodeId
/// <summary>
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
/// </summary>
public static class DiscoveredNodeMapper
{
/// <summary>
/// Maps captured <paramref name="nodes"/> into folders + variables (NodeIds scoped under
/// <paramref name="equipmentId"/>) plus a driver-FullReference → equipment-NodeId routing map.
/// </summary>
/// <param name="equipmentId">The owning equipment's NodeId (root of the grafted subtree).</param>
/// <param name="nodes">The captured discovery tree (from <c>CapturingAddressSpaceBuilder</c>).</param>
/// <param name="authoredRefs">
/// Driver FullReferences already authored as Config-DB equipment tags for this driver —
/// skipped so a discovered node never shadows an authored one.
/// </param>
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
public static DiscoveredInjectionPlan Map(
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
{
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
// device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
// FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
// level is retained so identical leaf names across devices don't collide (degrades gracefully).
var collapseIndex1 = kept.Count > 0
&& kept.All(n => n.FolderPathSegments.Count >= 2)
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
var variables = new List<DiscoveredVariable>();
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var n in kept)
{
var segs = Effective(n.FolderPathSegments, collapseIndex1);
// Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
// parent is the equipment itself).
for (var i = 0; i < segs.Count; i++)
{
var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = Combine(rootNodeId, folderPath);
if (folders.ContainsKey(nodeId)) continue;
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
}
var varFolderPath = string.Join('/', segs);
var varNodeId = string.IsNullOrEmpty(varFolderPath)
? Combine(rootNodeId, n.BrowseName)
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
// A folder-less variable parents directly at the root device node.
var varParent = string.IsNullOrEmpty(varFolderPath)
? rootNodeId
: Combine(rootNodeId, varFolderPath);
variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId;
}
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
}
/// <summary>
/// Maps a <see cref="DriverDataType"/> to the OPC-UA-built-in type STRING that
/// <c>OtOpcUaNodeManager.EnsureVariable</c>'s <c>ResolveBuiltInDataType</c> accepts — so a
/// discovered variable resolves to the same built-in type as an authored equipment tag. Most
/// enum names pass through verbatim; <see cref="DriverDataType.Float32"/>/<see cref="DriverDataType.Float64"/>
/// map to the SDK's "Float"/"Double" names, and <see cref="DriverDataType.Reference"/> (a Galaxy
/// attribute reference) is carried as an OPC UA String per the enum's own contract.
/// </summary>
private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
{
DriverDataType.Boolean => "Boolean",
DriverDataType.Int16 => "Int16",
DriverDataType.Int32 => "Int32",
DriverDataType.Int64 => "Int64",
DriverDataType.UInt16 => "UInt16",
DriverDataType.UInt32 => "UInt32",
DriverDataType.UInt64 => "UInt64",
DriverDataType.Float32 => "Float",
DriverDataType.Float64 => "Double",
DriverDataType.String => "String",
DriverDataType.DateTime => "DateTime",
DriverDataType.Reference => "String",
_ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
};
}
@@ -244,37 +244,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// value maps so stale condition state never leaks across redeploys.</summary>
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
/// <summary>In-flight <see cref="DriverInstanceActor.ApplyDelta"/> sends, keyed by correlation, so
/// <see cref="HandleApplyResult"/> can advance the cached spec only once the child confirms it adopted
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
/// <summary>The composition from the most-recent apply (set at the END of
/// <see cref="PushDesiredSubscriptions"/>). Discovered-node injection
/// (<see cref="HandleDiscoveredNodes"/>) reads it to resolve the equipment bound to a driver (from the
/// composition's <c>EquipmentNodes</c> whose <c>DriverInstanceId</c> matches, UNION the authored
/// <c>EquipmentTags</c> for that driver — so a driver with zero authored tags can still graft onto an
/// equipment bound via <c>EquipmentNode.DriverInstanceId</c>) and to recompute the authored value + alarm
/// subscription sets when merging FixedTree refs. Null until the first apply — a
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving before any apply is ignored.</summary>
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
private AddressSpaceComposition? _lastComposition;
/// <summary>The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy
/// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery.
/// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment
/// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one
/// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold
/// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable
/// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed.
/// Last-writer-wins on a re-discovery (the whole inner map is replaced).</summary>
private readonly Dictionary<string, Dictionary<string, DiscoveredInjectionPlan>> _discoveredByDriver =
new(StringComparer.Ordinal);
/// <summary>Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous
/// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a
/// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and
/// DEBUG-logged on the identical repeat passes. Folding in <see cref="_currentRevision"/> makes a redeploy
/// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is
/// separately short-circuited by <see cref="PlansRoutingEqual"/>. Cleared for a driver whose partition comes
/// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the
/// multi-candidate path (<see cref="PartitionDiscoveredByDeviceHost"/>).</summary>
private readonly Dictionary<string, string> _lastPartitionWarnSignature = new(StringComparer.Ordinal);
/// <summary>
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary
@@ -894,8 +872,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
@@ -928,8 +906,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
@@ -973,349 +951,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Handles a driver child's post-connect <see cref="DriverInstanceActor.DiscoveredNodesReady"/>:
/// resolves the equipment the driver is bound to from the most-recent applied composition (its
/// <c>EquipmentNodes</c> bound by <c>DriverInstanceId</c> UNION its authored <c>EquipmentTags</c>),
/// maps the captured FixedTree under it via <see cref="DiscoveredNodeMapper"/> (deduping any node that
/// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the
/// served address space + live-value maps + subscription set via
/// <see cref="ApplyDiscoveredPlansForDriver"/>. Idempotent / duplicate-safe: the mapper is pure,
/// materialisation is idempotent, and the routing-map extension + subscription merge are set-based.
/// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
{
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
// is a separate follow-up.
_log.Debug(
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
_localNode, msg.DriverInstanceId);
return;
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
if (_lastComposition is null)
{
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
_localNode, msg.DriverInstanceId);
return;
}
// Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose
// DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less
// equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a
// driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once.
var fromNodes = _lastComposition.EquipmentNodes
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(e => e.EquipmentId);
var fromTags = _lastComposition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(t => t.EquipmentId);
var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList();
if (equipmentIds.Count == 0)
{
_log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection",
_localNode, msg.DriverInstanceId);
return;
}
// Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never
// shadows an authored one — the mapper drops any captured node whose FullReference is already authored.
// May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps
// everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed,
// so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is
// correct per partition.
var authoredRefs = _lastComposition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(t => t.FullName)
.ToHashSet(StringComparer.Ordinal);
// Build this discovery's per-equipment plan map.
// • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single
// device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before.
// • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder
// segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E
// part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case
// (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost.
Dictionary<string, DiscoveredInjectionPlan> newPlans;
if (equipmentIds.Count == 1)
{
var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs);
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentIds[0]] = plan };
}
else
{
newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs);
if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged
}
// Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers
// every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each
// pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to
// UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping
// authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing
// is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This
// is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we
// short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.)
if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached)
&& PlansRoutingEqual(cached, newPlans))
{
var total = newPlans.Values.Sum(p => p.Variables.Count);
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped",
_localNode, msg.DriverInstanceId, total, newPlans.Count);
return;
}
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
}
/// <summary>
/// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment
/// (<c>FolderPathSegments[1]</c>) and maps each device's subset under the candidate equipment whose
/// <see cref="EquipmentNode.DeviceHost"/> matches — the multi-device graft. Returns
/// the per-equipment plan map (one entry per device that matched AND had at least one new variable);
/// EMPTY when nothing is graftable.
/// <list type="bullet">
/// <item>Builds <c>normalizedHost → equipmentId</c> from the candidate <see cref="EquipmentNode"/>s
/// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host
/// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment.</item>
/// <item><b>I1 divergence:</b> a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only,
/// no device binding) simply gets no partition — the FixedTree is the device's structure, so it
/// belongs under the device-bound equipment. No crash; that candidate is just not a partition target.</item>
/// <item>If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒
/// warn-skip the whole driver (returns empty).</item>
/// <item>A discovered partition whose host is unmatched (or whose node has &lt;2 folder segments, so no
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
/// </list>
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
/// <see cref="DeviceConfigIntent.NormalizeHost"/> (single source of truth), so they compare equal
/// regardless of case/whitespace.
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
/// <para><b>Mid-connect partition shrink.</b> If a later pass yields FEWER device partitions than a
/// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT
/// actively pruned until the next full redeploy (<see cref="PushDesiredSubscriptions"/> Clears + rebuilds
/// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption —
/// no mid-connect pruning is built here (out of scope).</para>
/// </summary>
private Dictionary<string, DiscoveredInjectionPlan> PartitionDiscoveredByDeviceHost(
DriverInstanceActor.DiscoveredNodesReady msg,
IReadOnlyList<string> equipmentIds,
IReadOnlySet<string> authoredRefs)
{
var driverId = msg.DriverInstanceId;
var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal);
// normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by
// two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily.
var hostToEquipment = new Dictionary<string, string>(StringComparer.Ordinal);
var ambiguousHosts = new HashSet<string>(StringComparer.Ordinal);
foreach (var node in _lastComposition!.EquipmentNodes)
{
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost);
if (ambiguousHosts.Contains(host)) continue;
if (hostToEquipment.TryGetValue(host, out var existing))
{
if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal))
{
hostToEquipment.Remove(host);
ambiguousHosts.Add(host);
}
continue;
}
hostToEquipment[host] = node.EquipmentId;
}
// DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the
// whole driver. (Falls through the same warn-once dedup as the unmatched case.)
if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0)
{
if (ShouldWarnPartition(driverId, "degenerate"))
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)",
_localNode, driverId, equipmentIds.Count);
else
_log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)",
_localNode, driverId, equipmentIds.Count);
return new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
}
// Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with
// <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate.
var matchedNodes = new Dictionary<string, List<DiscoveredNode>>(StringComparer.Ordinal);
var unmatchedHosts = new HashSet<string>(StringComparer.Ordinal);
foreach (var n in msg.Nodes)
{
var key = n.FolderPathSegments.Count >= 2
? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1])
: null;
if (key is not null && hostToEquipment.ContainsKey(key))
{
if (!matchedNodes.TryGetValue(key, out var list))
matchedNodes[key] = list = new List<DiscoveredNode>();
list.Add(n);
}
else
{
unmatchedHosts.Add(key ?? "(no-device-host-folder)");
}
}
// Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
// shadowed by authored refs) contributes no entry.
// NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW
// StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device
// emits one consistent HostAddress string per device, so a partition is single-host either way (collapse
// fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be
// a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope
// already isolates them).
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
foreach (var (host, nodes) in matchedNodes)
{
var equipmentId = hostToEquipment[host];
var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs);
if (plan.Variables.Count > 0) plans[equipmentId] = plan;
}
// Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions
// above still graft regardless. When the partition came back fully clean, drop the driver's signature so
// a later recurrence re-warns.
if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0)
{
var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal));
var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal));
if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous))
_log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted",
_localNode, driverId, unmatched, ambiguous);
else
_log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)",
_localNode, driverId, unmatched, ambiguous);
}
else
{
_lastPartitionWarnSignature.Remove(driverId);
}
return plans;
}
/// <summary>Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN)
/// when <paramref name="conditionKey"/> is newly-seen for the driver this revision, false (⇒ DEBUG) on the
/// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a
/// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only
/// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes
/// one duplicate warn to Debug.</summary>
private bool ShouldWarnPartition(string driverId, string conditionKey)
{
var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey;
var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev)
|| !string.Equals(prev, signature, StringComparison.Ordinal);
_lastPartitionWarnSignature[driverId] = signature;
return isNew;
}
/// <summary>Routing-map equality: same count + every key maps to the same NodeId. Lets
/// <see cref="HandleDiscoveredNodes"/> skip re-applying an unchanged discovered set across the driver's
/// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies).</summary>
private static bool RoutingEquals(IReadOnlyDictionary<string, string> a, IReadOnlyDictionary<string, string> b)
=> a.Count == b.Count
&& a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal));
/// <summary>Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the
/// same <see cref="DiscoveredInjectionPlan.RoutingByRef"/> (via <see cref="RoutingEquals"/>). Lets
/// <see cref="HandleDiscoveredNodes"/> short-circuit a re-discovery whose WHOLE per-driver set is unchanged
/// (a grown/changed set on any equipment differs and re-applies).</summary>
private static bool PlansRoutingEqual(
IReadOnlyDictionary<string, DiscoveredInjectionPlan> a,
IReadOnlyDictionary<string, DiscoveredInjectionPlan> b)
=> a.Count == b.Count
&& a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef));
/// <summary>
/// Grafts a driver's per-equipment <see cref="DiscoveredInjectionPlan"/> map onto the served state in
/// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition
/// follow-up needs without resubscribe churn):
/// <list type="number">
/// <item><b>Materialise per equipment</b> — for each <c>(equipmentId, plan)</c> entry, extend the
/// live-value routing map (mirroring <see cref="PushDesiredSubscriptions"/>' fan-out so
/// <see cref="ForwardToMux"/> lands FixedTree values on the right node) and Tell the publish actor
/// <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for
/// that equipment (idempotent).</item>
/// <item><b>Subscribe ONCE per driver</b> — compute the union of the driver's authored value refs
/// (recomputed the same way <see cref="PushDesiredSubscriptions"/> does) and the FixedTree refs of
/// ALL the driver's cached plans, then Tell the child a single
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> so the poll engine reads them and the
/// values flow. For a single-equipment driver this equals the prior per-plan behavior.</item>
/// </list>
/// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after
/// an address-space rebuild without re-running discovery.
/// </summary>
private void ApplyDiscoveredPlansForDriver(
string driverId, IReadOnlyDictionary<string, DiscoveredInjectionPlan> plansByEquipment)
{
// (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions'
// pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is
// purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes
// until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because
// a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect.
var totalVariables = 0;
foreach (var (equipmentId, plan) in plansByEquipment)
{
foreach (var (driverRef, nodeId) in plan.RoutingByRef)
{
var key = (driverId, driverRef);
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
}
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables));
totalVariables += plan.Variables.Count;
}
// (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the
// driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values.
// Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the
// FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the
// multi-device task adds inner-map entries without changing this single-send shape.
if (!_children.TryGetValue(driverId, out var entry)) return;
// The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it
// non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them
// so that re-apply path can't NRE.
var authoredValueRefs = _lastComposition is null
? Enumerable.Empty<string>()
: _lastComposition.EquipmentTags
.Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.FullName);
var alarmRefs = _lastComposition is null
? Array.Empty<string>()
: _lastComposition.EquipmentTags
.Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.FullName)
.Distinct(StringComparer.Ordinal)
.ToArray();
var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys);
var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray();
entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs));
_log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)",
_localNode, totalVariables, driverId, plansByEquipment.Count);
}
/// <summary>
/// Routes a native alarm transition (published by a driver child as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
@@ -1719,16 +1354,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<RouteNativeAlarmAck>(msg =>
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
_localNode, msg.ConditionNodeId));
// A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
}
@@ -2316,15 +1948,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
// is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the
// bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the
// authoreddiscovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully
// dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child
// to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the
// authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass.
var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal);
// One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND
// the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the
// SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained
@@ -2341,9 +1964,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var total = 0;
foreach (var (driverId, entry) in _children)
{
// Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached
// drivers keep the bulk authored-only send exactly as before.
if (cachedDriverIds.Contains(driverId)) continue;
total += SendAuthoredOnly(entry.Actor, driverId);
}
@@ -2380,94 +2000,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, composition.EquipmentScriptedAlarms.Count);
}
// Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve
// the equipment bound to a driver + recompute the authored subscription sets when a driver later
// reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore
// paths — which both route through this method — leave a current composition.
// Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and
// bootstrap-restore paths — which both route through this method — leave a current composition.
_lastComposition = composition;
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from the
// post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments
// from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes
// uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its
// equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to
// that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving
// entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has
// exactly one inner entry; the multi-device task adds more.)
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
{
var fromNodes = composition.EquipmentNodes
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(e => e.EquipmentId);
var fromTags = composition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.EquipmentId);
var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal);
var plansByEquipment = _discoveredByDriver[driverId];
// Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this
// driver's discovery exactly ONCE after the inner map is processed (see the post-loop block).
var droppedAny = false;
foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict
{
var plan = plansByEquipment[equipmentId];
if (!candidates.Contains(equipmentId))
{
plansByEquipment.Remove(equipmentId);
droppedAny = true;
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId);
continue;
}
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
var planEquipmentConsistent = plan.Variables.Count > 0
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
if (!planEquipmentConsistent)
{
plansByEquipment.Remove(equipmentId);
droppedAny = true;
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId);
}
}
// Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED
// rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT
// restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree
// subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now
// ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new
// _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no
// stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in
// DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is
// processed — so even when the inner map empties below), guarded on the child still existing.
if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry))
rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery());
if (plansByEquipment.Count == 0)
{
_discoveredByDriver.Remove(driverId);
// Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't
// leak a stale entry (log-level-only state; bounded by driver count — just tidiness).
_lastPartitionWarnSignature.Remove(driverId);
// FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its
// plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would
// otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
// set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass.
// (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps
// the authored values live meanwhile.) Guarded on the child still existing — a driver removed by
// ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk
// loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops
// the stale FixedTree handle without a spurious subscribe).
if (_children.TryGetValue(driverId, out var fallbackEntry))
SendAuthoredOnly(fallbackEntry.Actor, driverId);
continue;
}
ApplyDiscoveredPlansForDriver(driverId, plansByEquipment);
}
}
private void SpawnChild(DriverInstanceSpec spec)
@@ -2479,7 +2015,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
catch (Exception ex)
{
_log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing",
// A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing;
// device I/O happens later in InitializeAsync. Logged at Error because the node silently
// degrades to a stub that answers nothing, and since #516 routed DriverConfig changes
// through a respawn this path is now reachable by an ordinary operator edit rather than
// only by a brand-new driver. It still does NOT fail the deployment — making it do so is
// a deliberate follow-up, since it would let one malformed driver block a fleet deploy.
_log.Error(ex,
"DriverHost {Node}: factory for {Type} REJECTED the config for {Id} — the driver is " +
"stubbed and will serve nothing until the config is fixed and redeployed",
_localNode, spec.DriverType, spec.DriverInstanceId);
}
if (driver is null)
@@ -2559,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Context.Stop(adapter);
}
/// <summary>
/// Sends an in-place config delta to a running child.
/// <para><b>Unreachable from the reconcile path since #516</b> — a DriverConfig change is now a
/// stop + respawn (<see cref="DriverSpawnPlanner"/>), so <c>ToApplyDelta</c> is always empty.
/// Kept because the seam is still exercised by <c>DriverInstanceActor</c>'s own mid-connect config
/// adoption.</para>
/// <para><b>Two seals were removed here.</b> This used to overwrite the cached
/// <c>Spec</c> SYNCHRONOUSLY, before the child had even dequeued the message — so the host
/// immediately believed the new config was live, the NEXT reconcile computed no delta against it,
/// and any drift was sealed permanently. It also <c>Tell</c>d with no <c>Receive&lt;ApplyResult&gt;</c>
/// handler registered, so a FAILED reinit — including Galaxy's deliberate
/// <c>NotSupportedException</c> — dead-lettered and was never surfaced.</para>
/// </summary>
private void ApplyChildDelta(DriverInstanceSpec spec)
{
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
// Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config.
_children[spec.DriverInstanceId] = entry with { Spec = spec };
var correlation = CorrelationId.NewId();
_pendingDelta[correlation] = spec;
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
}
/// <summary>
/// A child's reply to <see cref="ApplyChildDelta"/>. On success the cached <c>Spec</c> is advanced —
/// only now, when the child has actually adopted the config, so a failed reinit leaves the host
/// believing the OLD config is live and the next reconcile re-attempts the change instead of
/// silently treating the drift as applied. A failure is logged at Error rather than swallowed.
/// </summary>
private void HandleApplyResult(DriverInstanceActor.ApplyResult msg)
{
if (msg.Success)
{
if (_pendingDelta.Remove(msg.Correlation, out var spec)
&& _children.TryGetValue(spec.DriverInstanceId, out var entry))
{
// A delta can change Name, Enabled, ClusterId etc. alongside the config.
_children[spec.DriverInstanceId] = entry with { Spec = spec };
}
return;
}
_pendingDelta.Remove(msg.Correlation, out var failed);
_log.Error(
"DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " +
"the previous config so the next reconcile re-attempts it",
_localNode, failed?.DriverInstanceId ?? "<unknown>", msg.Reason ?? "no reason given");
}
/// <summary>
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
@@ -1,5 +1,6 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -32,15 +33,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
/// <summary>Default interval between bounded post-connect re-discovery passes.</summary>
public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2);
/// <summary>Default cap on the number of post-connect re-discovery passes.</summary>
public const int DefaultRediscoverMaxAttempts = 15;
/// <summary>Default per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during
/// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s.</summary>
public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation);
@@ -124,25 +120,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// subscription that un-gates an <see cref="IAlarmSource"/> driver's feed. Handled async so the
/// <see cref="IAlarmSource.SubscribeAlarmsAsync"/> call is bounded + off the synchronous handlers.</summary>
private sealed record SubscribeAlarms;
/// <summary>Published to the parent (DriverHostActor) after each post-connect discovery pass so it can
/// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine —
/// the parent dedups and injection is idempotent.</summary>
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
/// <summary>Self-sent when the wrapped driver raises <see cref="IRediscoverable.OnRediscoveryNeeded"/> —
/// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread
/// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no
/// connection affinity (a Galaxy redeploy or a TwinCAT symbol-version bump can land while the driver is
/// between connects), and dropping it in one state would lose the signal silently.</summary>
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
/// <summary>
/// Sent by <see cref="DriverHostActor"/> to ask this driver child to re-run post-connect discovery
/// after the host rebinds the driver to a new equipment. Handled only in <c>Connected</c>, where it
/// re-kicks <see cref="StartDiscovery"/> — which already honours the driver's
/// <see cref="ITagDiscovery.RediscoverPolicy"/> and the <see cref="ITagDiscovery"/> guard, tagging the
/// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op:
/// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log.
/// </summary>
public sealed record TriggerRediscovery;
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
/// <see cref="QualifiesForDriftCheck"/>.</summary>
private sealed record DriftCheckTick
{
public static readonly DriftCheckTick Instance = new();
private DriftCheckTick() { }
}
/// <summary>Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~02s after connect).
/// <paramref name="PreviousSignature"/> is the ordered-distinct full-reference signature of the prior pass's
/// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it.</summary>
private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature);
public sealed class RetryConnect
{
public static readonly RetryConnect Instance = new();
@@ -167,19 +160,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly IDriverHealthPublisher _healthPublisher;
private readonly TimeSpan _reconnectInterval;
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
/// inject a tiny value so the loop runs without real-time waits.</summary>
private readonly TimeSpan _rediscoverInterval;
private readonly TimeSpan _healthPollInterval;
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
private readonly int _rediscoverMaxAttempts;
/// <summary>Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during bounded post-connect
/// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter
/// value. Stored to allow injection rather than hardcoding.</summary>
private readonly TimeSpan _rediscoverDiscoverTimeout;
private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson;
@@ -203,6 +185,24 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private ISubscriptionHandle? _subscriptionHandle;
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
/// <summary>When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, and the reason
/// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can
/// prompt an operator to re-browse the device.
/// <para>Deliberately <b>sticky</b> — it is not cleared on reconnect or on a later clean pass. The remote's
/// tag set stayed changed; only an operator re-browsing and committing resolves it, and this actor cannot
/// observe that happening. A redeploy respawns the child, which clears it naturally.</para></summary>
private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason;
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
private readonly TimeSpan _driftCheckInterval;
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
/// which reads as "it just happened again" rather than "it is still true".</summary>
private string? _lastDriftSignature;
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
@@ -235,11 +235,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// stub paths don't need to provide one.</param>
/// <param name="clusterId">Optional cluster identifier forwarded in <see cref="DriverHealthChanged"/> messages;
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
@@ -250,22 +249,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) =>
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
startStubbed,
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
rediscoverInterval,
rediscoverMaxAttempts,
rediscoverDiscoverTimeout,
invoker,
healthPollInterval));
healthPollInterval,
driftCheckInterval));
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -294,11 +289,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="startStubbed">If true, start in stub mode for testing or unavailable platforms.</param>
/// <param name="healthPublisher">Sink for health-change notifications; must not be null.</param>
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
/// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param>
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor(
@@ -307,21 +301,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null)
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null)
{
_driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval;
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
_rediscoverMaxAttempts = rediscoverMaxAttempts;
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
@@ -344,6 +334,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Warm up the snapshot store immediately so AdminUI sees current state as soon as the
// actor starts, before any state transition fires. Also start the periodic heartbeat so
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
// Attach the rediscovery signal before the first publish. Not per-connect: an IRediscoverable raise
// has no connection affinity, and a driver can observe a remote change while disconnected.
AttachRediscoverySource();
PublishHealthSnapshot();
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
}
@@ -362,9 +355,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
// re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message.
Receive<RediscoverTick>(_ => { });
// A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it.
Receive<TriggerRediscovery>(_ => { });
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
}
@@ -390,7 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDiscovery();
StartDriftCheck();
});
Receive<InitializeFailed>(msg =>
{
@@ -418,12 +409,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
Receive<SubscribeAlarms>(_ => { });
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
Receive<RediscoverTick>(_ => { });
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
Receive<TriggerRediscovery>(_ => { });
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
}
@@ -439,34 +425,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
_driverInstanceId, msg.Reason);
Timers.Cancel("rediscover");
DetachSubscription();
RecordFault();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
Receive<ForceReconnect>(_ =>
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
Timers.Cancel("rediscover");
DetachSubscription();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
ReceiveAsync<RediscoverTick>(HandleRediscoverAsync);
// The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any
// pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left
// over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop
// via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current
// _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick
// that still slips through (one already mid-async-handler) is benign: the parent dedups
// DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double
// pass in the common case.
Receive<TriggerRediscovery>(_ =>
{
Timers.Cancel("rediscover");
StartDiscovery();
});
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
@@ -486,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms();
});
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
Receive<DataChangeForward>(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -499,6 +472,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
Receive<SubscriptionFailed>(msg =>
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
Receive<HealthPollTick>(_ =>
{
PublishHealthSnapshot();
@@ -579,7 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed
StartDriftCheck();
});
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -602,12 +576,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
Receive<SubscribeAlarms>(_ => { });
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
Receive<RediscoverTick>(_ => { });
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
Receive<TriggerRediscovery>(_ => { });
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
}
@@ -829,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
private void StopDriftCheck() => Timers.Cancel("drift-check");
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
@@ -853,6 +826,157 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
src.OnAlarmEvent += _alarmEventHandler;
}
/// <summary>Subscribe the driver's <see cref="IRediscoverable.OnRediscoveryNeeded"/> (if it is one),
/// marshaling each raise to the actor thread. Idempotent; mirrors <see cref="AttachAlarmSource"/>.
/// <para>Attached once in <c>PreStart</c> rather than per-connect, because the interesting raises
/// (a Galaxy redeploy, a TwinCAT symbol-version bump) can happen while the driver is between
/// connects, and the event carries no connection affinity.</para></summary>
private void AttachRediscoverySource()
{
if (_driver is not IRediscoverable src || _rediscoveryHandler is not null) return;
var self = Self;
_rediscoveryHandler = (_, e) => self.Tell(new RediscoveryRaised(e));
src.OnRediscoveryNeeded += _rediscoveryHandler;
}
/// <summary>Symmetric teardown, called from PostStop. Load-bearing: the <see cref="IDriver"/> instance
/// can OUTLIVE this actor (the host respawns a child around the same driver object), so a missing
/// unsubscribe would accumulate one handler per respawn, each holding a dead <c>Self</c>.</summary>
private void DetachRediscoverySource()
{
if (_driver is IRediscoverable src && _rediscoveryHandler is not null)
src.OnRediscoveryNeeded -= _rediscoveryHandler;
_rediscoveryHandler = null;
}
/// <summary>
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
/// coverage.</para>
/// </summary>
private bool QualifiesForDriftCheck() =>
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
private void StartDriftCheck()
{
if (!QualifiesForDriftCheck()) return;
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
// report phantom drift on every reconnect.
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
}
/// <summary>
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
/// compare is the only way to notice a PLC program re-download.
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
/// </summary>
private async Task HandleDriftCheckAsync(DriftCheckTick _)
{
if (_driver is not ITagDiscovery discovery) return;
var authored = discovery.AuthoredDiscoveryRefs;
if (authored is null) return;
CapturedTree tree;
try
{
var builder = new CapturingAddressSpaceBuilder();
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
// block writes, reconnects and health polls behind it.
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
await _invoker.ExecuteAsync(
DriverCapability.Discover,
_driverInstanceId,
async ct => await discovery.DiscoverAsync(builder, ct),
cts.Token);
tree = builder.Build();
}
catch (Exception ex)
{
// A failed browse is not drift — the device may simply be unreachable, which the health surface
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
return;
}
if (tree.Truncated)
{
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
_log.Warning(
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
_driverInstanceId);
return;
}
var discovered = new List<string>();
CollectLeafRefs(tree.Root, discovered);
var drift = TagSetDriftDetector.Compare(
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
if (!drift.HasDrift)
{
// Recovered: the device matches the authored set again, so drop the prompt and let the next
// genuine drift re-raise with a fresh timestamp.
if (_lastDriftSignature is not null)
{
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
_driverInstanceId);
_lastDriftSignature = null;
_rediscoveryNeededUtc = null;
_rediscoveryReason = null;
PublishHealthSnapshot();
}
return;
}
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
// as "it just happened again" rather than "it is still true".
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
_lastDriftSignature = drift.Signature;
_rediscoveryNeededUtc = DateTime.UtcNow;
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
_log.Info(
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
_driverInstanceId, drift.Describe());
PublishHealthSnapshot();
}
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
private static void CollectLeafRefs(CapturedNode node, List<string> into)
{
if (node.IsLeaf) into.Add(node.Id);
foreach (var child in node.Children) CollectLeafRefs(child, into);
}
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
/// explicitly through the <c>/raw</c> browse-commit flow, so a runtime graft would create nodes no
/// operator approved and that no deployment artifact records. This prompts a human to re-browse.</para></summary>
private void HandleRediscoveryRaised(RediscoveryRaised msg)
{
_rediscoveryNeededUtc = DateTime.UtcNow;
_rediscoveryReason = msg.Args.Reason;
_log.Info(
"DriverInstance {Id}: driver reports its tag set may have changed ({Reason}) — surfaced for operator re-browse; the served address space is unchanged",
_driverInstanceId, msg.Args.Reason);
PublishHealthSnapshot();
}
/// <summary>Symmetric teardown — called from <see cref="DetachSubscription"/> and PostStop so a stale
/// handler never pushes to a disconnected actor.</summary>
private void DetachAlarmSource()
@@ -909,97 +1033,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Kick the bounded post-connect re-discovery loop on a <c>Connected</c> entry. A no-op unless the
/// driver exposes <see cref="ITagDiscovery"/> (nothing to inject otherwise). Self-sends the first
/// <see cref="RediscoverTick"/> tagged with the current init generation so a tick that outlives a reconnect
/// is rejected by the generation guard in <see cref="HandleRediscoverAsync"/>.
/// <para>Honours the driver's <see cref="ITagDiscovery.RediscoverPolicy"/>: <c>Never</c> opts out entirely
/// (no tick scheduled); <c>Once</c> runs a single pass (the loop stops after the first publish in
/// <see cref="HandleRediscoverAsync"/>); <c>UntilStable</c> retries each (re)connect, bounded by
/// stop-on-stable (the discovered-set signature repeats) + the attempt cap.</para></summary>
private void StartDiscovery()
{
if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never)
{
// Driver opts out of post-connect discovery — don't even schedule the first tick.
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId);
return;
}
Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty));
}
/// <summary>Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a
/// <see cref="CapturingAddressSpaceBuilder"/> and ships the result to the parent as
/// <see cref="DiscoveredNodesReady"/> (empty/duplicate sets are fine — the parent dedups and injection
/// is idempotent). Retries on the <see cref="_rediscoverInterval"/> until the non-empty discovered SET
/// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged
/// browsers where a count alone could falsely settle a partial tree) or the <see cref="_rediscoverMaxAttempts"/>
/// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may
/// still be populating.
/// <para>Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for
/// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config <c>_options.Tags</c> is
/// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and
/// later grows could stop early on a transient repeat; acceptable for current scope.</para></summary>
private async Task HandleRediscoverAsync(RediscoverTick tick)
{
if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass)
if (_driver is not ITagDiscovery discovery) return;
IReadOnlyList<DiscoveredNode> nodes;
try
{
var builder = new CapturingAddressSpaceBuilder();
// Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
// DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
// NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy /
// OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell +
// Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume
// off-context and throw NotSupportedException("no active ActorContext"). The invoker's own
// internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor
// continuation still resumes on the captured actor scheduler. (Discover retries per tier.)
await _invoker.ExecuteAsync(
DriverCapability.Discover,
_driverInstanceId,
async ct => await discovery.DiscoverAsync(builder, ct),
cts.Token);
nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors
}
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt);
nodes = Array.Empty<DiscoveredNode>();
}
// Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so
// _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on
// disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes.
if (tick.Generation != _initGeneration) return;
Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes));
// Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per
// (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is
// complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here —
// StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable +
// attempt-cap logic below.
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once)
{
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId);
return;
}
// Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap
// is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "".
var signature = string.Join('\u0001',
nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal));
var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal);
if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty)
Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval);
else
_log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count);
}
/// <summary>Records the host's desired subscription set without touching the live subscription.
/// The set is (re)applied by <see cref="ResubscribeDesired"/> on the next <c>Connected</c> entry.</summary>
@@ -1081,11 +1114,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
var health = _driver.GetHealth();
var errorCount = ErrorCount5Min();
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount);
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
// identical, so without it the dedup below would swallow the very publish that carries the
// signal and the operator would never see the prompt.
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, _rediscoveryNeededUtc);
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
return;
_lastPublishedFingerprint = fingerprint;
_healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount);
_healthPublisher.Publish(
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason);
}
catch (Exception ex)
{
@@ -1094,12 +1132,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
/// <summary>Fingerprint of the last <see cref="PublishHealthSnapshot"/> call; null until first publish.</summary>
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount)? _lastPublishedFingerprint;
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, DateTime? RediscoveryNeededUtc)? _lastPublishedFingerprint;
/// <inheritdoc />
protected override void PostStop()
{
StopDriftCheck();
DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
DetachRediscoverySource();
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// spawn / ApplyDelta / stop on its child actors accordingly.
/// </summary>
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
/// <param name="ToApplyDelta">Specs whose child exists but config JSON or type differs.</param>
/// <param name="ToApplyDelta">
/// In-place config deltas. <b>Always empty since #516</b> — a DriverConfig change is now a
/// stop + respawn so the factory is the single parse authority. Retained because
/// <c>DriverInstanceActor</c> still handles <c>ApplyDelta</c> on its own config-adoption path
/// (a config arriving mid-connect), and removing the list would hide that seam.
/// </param>
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
public sealed record DriverSpawnPlan(
IReadOnlyList<DriverInstanceSpec> ToSpawn,
@@ -42,23 +47,33 @@ public static class DriverSpawnPlanner
toStop.Add(id);
continue;
}
// A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
// A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its
// resolved options) is bound to the child actor at spawn time, so the only way a changed
// ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale
// cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an
// in-place delta — no reconnect — because it doesn't touch the resilience pipeline.)
// ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the
// single parse authority for everything a driver is built from.
//
// • DriverType — factory-bound, can't be reinitialized in place.
// • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child
// actor at spawn time; the factory invalidates the stale cached pipelines on respawn.
// • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn.
//
// #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS,
// OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their
// constructor and never looked at the driverConfigJson they were handed. The deployment still
// sealed green. Those five now re-parse (belt), and this respawn is the braces: several
// drivers build MORE than options from config — Sql derives its dialect + connection string
// and FOCAS its client-factory backend, both injected at construction — so an in-place
// re-parse of options ALONE would apply a new tag set against an old connection. Only a
// respawn rebuilds all of it.
//
// The accepted cost is a reconnect on every DriverConfig edit, replacing the prior
// no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op.
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal)
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal))
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal)
|| !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
{
toStop.Add(id);
toSpawn.Add(spec);
continue;
}
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
{
toDelta.Add(spec);
}
}
foreach (var (id, spec) in targetById)
@@ -0,0 +1,92 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// The difference between what a driver <b>discovers</b> on its remote and what an operator
/// <b>authored</b> — i.e. whether the device's tag set has moved out from under the deployed config.
/// </summary>
/// <param name="Appeared">Refs the remote now offers that no authored tag binds.</param>
/// <param name="Vanished">
/// Refs authored tags bind that the remote no longer offers. <b>Always empty when
/// <paramref name="VanishedDetectable"/> is false</b> — see that parameter.
/// </param>
/// <param name="VanishedDetectable">
/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
/// ones (<c>ITagDiscovery.DiscoveryStreamIncludesAuthoredTags</c>). The discovered set then always
/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
/// <see cref="Describe"/> can say "new-on-device only" rather than implying a clean bill of health.
/// </param>
public sealed record TagSetDrift(
IReadOnlyList<string> Appeared,
IReadOnlyList<string> Vanished,
bool VanishedDetectable)
{
/// <summary>True when the two sets differ in either direction.</summary>
public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
/// <summary>
/// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
/// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
/// drift look new.
/// </summary>
public string Signature { get; } =
string.Join('', Appeared) + '' + string.Join('', Vanished);
/// <summary>An operator-facing one-liner naming what moved, suitable for a UI tooltip.</summary>
/// <returns>A short human-readable description, or "no drift" when there is none.</returns>
public string Describe()
{
if (!HasDrift) return "no drift";
var parts = new List<string>(3);
if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
// Say so rather than let the absence of a "missing" clause read as "nothing is missing".
if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
return string.Join("; ", parts);
}
/// <summary>Names at most two refs so the message stays readable when a whole PLC program is swapped.</summary>
private static string Sample(IReadOnlyList<string> refs) =>
refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
}
/// <summary>
/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
/// actor's periodic check stays cheap; kept out of <c>DriverInstanceActor</c> so it is directly testable
/// without an actor system.
/// </summary>
public static class TagSetDriftDetector
{
/// <summary>
/// Computes the two-way difference.
/// <para><b>Ordinal comparison</b>, matching how every driver keys its tag tables — a device that
/// genuinely exposes both <c>Speed</c> and <c>speed</c> must not have them collapsed.</para>
/// </summary>
/// <param name="discovered">Refs streamed by the driver's most recent discovery pass.</param>
/// <param name="authored">Refs the driver's authored tags bind (<c>ITagDiscovery.AuthoredDiscoveryRefs</c>).</param>
/// <param name="detectVanished">
/// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
/// structurally invisible. The vanished half is then not computed at all rather than computed to a
/// misleading empty — see <see cref="TagSetDrift.VanishedDetectable"/>.
/// </param>
/// <returns>The drift, which may be empty.</returns>
public static TagSetDrift Compare(
IReadOnlyCollection<string> discovered,
IReadOnlyCollection<string> authored,
bool detectVanished = true)
{
ArgumentNullException.ThrowIfNull(discovered);
ArgumentNullException.ThrowIfNull(authored);
var authoredSet = new HashSet<string>(authored, StringComparer.Ordinal);
var discoveredSet = new HashSet<string>(discovered, StringComparer.Ordinal);
var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray();
var vanished = detectVanished
? authoredSet.Where(r => !discoveredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray()
: [];
return new TagSetDrift(appeared, vanished, detectVanished);
}
}
@@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
public sealed record RebuildAddressSpace(
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
/// <param name="EquipmentRootNodeId">The OPC UA NodeId of the equipment root folder to inject the
/// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is
/// announced under.</param>
public sealed record MaterialiseDiscoveredNodes(
string EquipmentRootNodeId,
IReadOnlyList<DiscoveredFolder> Folders,
IReadOnlyList<DiscoveredVariable> Variables);
public sealed record ServiceLevelChanged(byte ServiceLevel);
private readonly IOpcUaAddressSpaceSink _sink;
@@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
@@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under
/// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the
/// optional-applier tolerance of <see cref="HandleRebuild"/>.</summary>
private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
{
if (_applier is null)
{
_log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId);
return;
}
var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
if (failedNodes > 0)
{
// archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the
// dedicated meter instead of vanishing into per-node Warnings.
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair<string, object?>("kind", "nodes"));
_log.Error(
"OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing",
msg.EquipmentRootNodeId, failedNodes);
}
}
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
{
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Gitea #516 — <c>AbLegacyDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
/// was silently discarded while the deployment still sealed green.
/// <para>Every pre-existing reinit test in this suite passes <c>"{}"</c> — the exact input the guarded
/// re-parser treats as "keep the constructor options" — so they were blind to the defect.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReinitConfigAdoptionTests
{
/// <summary>
/// The driver validates every device's <c>HostAddress</c> during init. Reinitializing with a config
/// whose device address is structurally invalid must therefore THROW — a driver still serving the
/// constructor's valid device list would validate that instead and succeed, which is precisely how
/// the discarded edit stayed invisible.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_device_list()
{
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
"ab1",
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
loggerFactory: null);
await Should.ThrowAsync<InvalidOperationException>(
() => driver.ReinitializeAsync(
"""{"devices":[{"hostAddress":"not-a-valid-ab-address","plcFamily":"Slc500"}]}""",
CancellationToken.None),
"AbLegacyDriver kept its constructor-supplied device list after a reinitialize with a changed config (#516)");
}
/// <summary>An empty/placeholder document must still keep the constructor options, so the many
/// lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
{
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
"ab1",
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
loggerFactory: null);
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
}
}
@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config:
/// the <c>Backend</c> key selects its <see cref="IFocasClientFactory"/>, injected at construction. Adopting
/// a new <c>FocasDriverOptions</c> alone would poll a NEW device/tag set through the OLD backend, which
/// looks like it worked and does not.
/// <para><c>ParseBinding</c> now returns options + backend together and <c>InitializeAsync</c> adopts both
/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only
/// checked options would have passed against the broken version.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitConfigAdoptionTests
{
/// <summary>
/// Reinitializing onto <c>Backend: "unimplemented"</c> must make the driver adopt that backend, whose
/// <c>EnsureUsable()</c> throws by design. A driver still holding the constructor's <c>wire</c>
/// backend would not throw — so the throw is a direct read of which backend is live.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"wire","devices":[]}""");
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
"""{"backend":"unimplemented","devices":[]}""", CancellationToken.None));
ex.ShouldBeOfType<NotSupportedException>(
"FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied "
+ "adoption (#516) that would poll a new device/tag set through the old backend");
}
/// <summary>The other direction: moving OFF the unimplemented backend must also take effect, so the
/// adoption is not a one-way latch.</summary>
[Fact]
public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"unimplemented","devices":[]}""");
// Sanity: the constructor-supplied backend is the throwing one.
(await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None)))
.ShouldBeOfType<NotSupportedException>();
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
"""{"backend":"wire","devices":[]}""", CancellationToken.None));
ex.ShouldNotBeOfType<NotSupportedException>(
"FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'");
}
/// <summary>An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests
/// that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend()
{
var driver = FocasDriverFactoryExtensions.CreateInstance(
"focas-1", """{"backend":"unimplemented","devices":[]}""");
(await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)))
.ShouldBeOfType<NotSupportedException>();
}
}
@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #516 — <see cref="ModbusDriver.InitializeAsync"/> used to serve the options its CONSTRUCTOR
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
/// was silently discarded while the deployment still sealed green.
/// <para><b>This test passes a CHANGED config on purpose.</b> Every pre-existing reinit test in this
/// suite passes <c>"{}"</c> — exactly the input the guarded re-parser treats as "keep the constructor
/// options" — so those tests were blind to the defect by construction.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusReinitConfigAdoptionTests
{
/// <summary>The transport factory receives the options the driver actually decided to use, so it is a
/// direct read of which config won — no log-string matching.</summary>
[Fact]
public async Task Reinitialize_with_a_changed_host_rebuilds_the_transport_against_the_new_host()
{
var seen = new List<string>();
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
var driver = new ModbusDriver(options, "m1", opts =>
{
seen.Add($"{opts.Host}:{opts.Port}");
return new NeverConnectingTransport();
});
try { await driver.InitializeAsync("""{"host":"10.0.0.1","port":502}""", CancellationToken.None); }
catch { /* connect failure is expected and irrelevant */ }
try { await driver.ReinitializeAsync("""{"host":"10.0.0.99","port":1502}""", CancellationToken.None); }
catch { /* ditto */ }
seen.Count.ShouldBeGreaterThanOrEqualTo(2);
seen[^1].ShouldBe("10.0.0.99:1502",
"ModbusDriver kept its constructor-supplied host after a reinitialize with a changed config (#516)");
}
/// <summary>A placeholder / empty document must still keep the constructor options — the guard exists so
/// the many lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
{
var seen = new List<string>();
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
var driver = new ModbusDriver(options, "m1", opts =>
{
seen.Add($"{opts.Host}:{opts.Port}");
return new NeverConnectingTransport();
});
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
catch { /* connect failure is expected */ }
seen.ShouldAllBe(s => s == "10.0.0.1:502");
}
private sealed class NeverConnectingTransport : IModbusTransport
{
public bool IsConnected => false;
public Task ConnectAsync(CancellationToken cancellationToken)
=> throw new IOException("test transport never connects");
public Task DisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken cancellationToken)
=> throw new IOException("test transport never connects");
public void Dispose() { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Gitea #516 — <c>OpcUaClientDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
/// captured. Its own comment claimed "resolving on every InitializeAsync … picks up rotations", which
/// was true of SECRET rotation only: the endpoint, the security policy and the tag set were all frozen
/// at construction, so an operator's edit was discarded while the deployment still sealed green.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReinitConfigAdoptionTests
{
/// <summary>
/// <c>ValidateNamespaceKind</c> runs at the top of init and rejects <c>TargetNamespaceKind=Equipment</c>
/// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver
/// still serving the constructor's valid options would validate those and succeed, which is exactly
/// how the discarded edit stayed invisible.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_config()
{
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
"opc1",
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
await Should.ThrowAsync<InvalidOperationException>(
() => driver.ReinitializeAsync(
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""",
CancellationToken.None),
"OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)");
}
/// <summary>An empty/placeholder document must still keep the constructor options.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
{
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
"opc1",
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
// "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as
// far as the connect — which fails against an unreachable endpoint. Anything BUT the validation
// throw proves the constructor options survived.
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
(ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase))
.ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config");
}
}
@@ -0,0 +1,120 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config
/// (its <see cref="ISqlDialect"/> and its resolved connection string are both config-derived), so
/// adopting a new <c>SqlDriverOptions</c> alone would poll a NEW tag set through the OLD database.
/// A half-applied config change is worse than a discarded one, because it looks like it worked.
/// <para>The fix is <c>ParseBinding</c> + <c>ApplyBinding</c>: one parse produces all three, and they are
/// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the
/// property that made this driver special.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class SqlReinitConfigAdoptionTests
{
private const string RefA = "SqlReinitTestA";
private const string RefB = "SqlReinitTestB";
/// <summary>
/// A reinitialize that changes <c>connectionStringRef</c> must move the driver onto the NEW database.
/// <c>Endpoint</c> is the credential-free rendering of the live connection string, so it is a direct
/// read of which connection the driver actually adopted — no log-string matching.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
using var __ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB,
"Server=new-server;Database=NewDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
driver.Endpoint.ShouldContain("old-server");
// The database is unreachable, so init faults — irrelevant here. The assertion is which database it
// tried, i.e. whether the connection string moved with the options.
try
{
await driver.ReinitializeAsync(
$$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None);
}
catch
{
// Liveness failure against a non-existent server is expected.
}
driver.Endpoint.ShouldContain(
"new-server",
customMessage:
"SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact "
+ "half-applied adoption (#516) that would poll a new tag set against the old database");
driver.Endpoint.ShouldNotContain("old-server");
}
/// <summary>
/// A config whose <c>connectionStringRef</c> names an unprovisioned environment variable must make
/// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where
/// new options are live against the old connection.
/// </summary>
[Fact]
public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(
"""{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""",
CancellationToken.None));
driver.Endpoint.ShouldContain(
"old-server",
customMessage:
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
}
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
/// tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding()
{
using var _ = new ScopedEnvironmentVariable(
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
"Server=old-server;Database=OldDb;Integrated Security=true");
var driver = SqlDriverFactoryExtensions.CreateInstance(
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
catch { /* liveness failure expected */ }
driver.Endpoint.ShouldContain("old-server");
}
/// <summary>Sets an environment variable for the duration of a test and restores it after. The resolver
/// reads process-global state, so a leaked variable would race every sibling test.</summary>
private sealed class ScopedEnvironmentVariable : IDisposable
{
private readonly string _name;
private readonly string? _previous;
public ScopedEnvironmentVariable(string name, string value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
/// <summary>
/// Closes <c>deferment.md</c> G-4. A guard already existed for <c>DriverTypeNames</c> ↔ the <c>/raw</c>
/// driver PICKER, but not for the two dispatch maps downstream of it, so a driver could be registered,
/// offered in the picker, and then have no config form — which is exactly what happened to
/// <c>Calculation</c> (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived
/// review because nothing could see them.
/// <para>These are ordinary map lookups rather than reflection over a private field: the two modals were
/// converted from a Razor <c>@switch</c> — unreachable from a test, since it compiles into
/// <c>BuildRenderTree</c>'s IL — to <c>DriverConfigFormMap</c> / <c>DeviceFormMap</c>. There is no bUnit
/// in this project, so a map is the only thing a test can hold on to.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverFormMapParityTests
{
/// <summary>Every declared driver type must have a typed config form. There is no exemption: a driver
/// with no configurable surface still needs a form to say so, or the operator meets a bare warning.</summary>
[Fact]
public void Every_declared_driver_type_has_a_typed_config_form()
{
var missing = DriverTypeNames.All
.Where(t => DriverConfigFormMap.Resolve(t) is null)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these driver types are registered and offered in the /raw picker but DriverConfigModal has no "
+ $"form for them, so their config is unauthorable through the UI: {string.Join(", ", missing)}");
}
/// <summary>The reverse direction — a mapped type that is no longer a declared driver type is a stale
/// entry, and would keep a deleted driver's form reachable.</summary>
[Fact]
public void Every_config_form_maps_to_a_declared_driver_type()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stale = DriverConfigFormMap.MappedDriverTypes
.Where(t => !declared.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
stale.ShouldBeEmpty($"DriverConfigFormMap has entries for undeclared driver types: {string.Join(", ", stale)}");
}
/// <summary>
/// Every declared driver type must EITHER have a typed device form OR be declared single-connection.
/// <para>The either/or matters: demanding a device form from every driver would be wrong (Sql has one
/// connection string, MTConnect one Agent URI, Calculation no connection at all), and a test that has
/// to be suppressed for five drivers stops being read. Forcing the choice to be explicit is what makes
/// this a guard — a new driver cannot silently fall through.</para>
/// </summary>
[Fact]
public void Every_declared_driver_type_has_a_device_form_or_is_declared_single_connection()
{
var unaccounted = DriverTypeNames.All
.Where(t => DeviceFormMap.Resolve(t) is null && !DeviceFormMap.IsSingleConnection(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
unaccounted.ShouldBeEmpty(
$"these driver types have neither a typed device form nor a place in "
+ $"DeviceFormMap.SingleConnectionDriverTypes, so DeviceModal falls through to its "
+ $"\"no typed device form\" warning: {string.Join(", ", unaccounted)}");
}
/// <summary>The reverse direction for the device map.</summary>
[Fact]
public void Every_device_form_maps_to_a_declared_driver_type()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stale = DeviceFormMap.MappedDriverTypes
.Concat(DeviceFormMap.SingleConnectionDriverTypes)
.Where(t => !declared.Contains(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
stale.ShouldBeEmpty($"DeviceFormMap references undeclared driver types: {string.Join(", ", stale)}");
}
/// <summary>
/// A driver that authors its connection on the DRIVER form must have a driver form to author it on.
/// Without this, declaring a type single-connection would be a way to opt out of both maps at once
/// and leave the connection unauthorable anywhere.
/// </summary>
[Fact]
public void Every_single_connection_driver_has_a_driver_config_form()
{
var missing = DeviceFormMap.SingleConnectionDriverTypes
.Where(t => DriverConfigFormMap.Resolve(t) is null)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these types are declared single-connection but have no driver config form, so their connection "
+ $"cannot be authored anywhere: {string.Join(", ", missing)}");
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Parity guards for the two remaining per-driver dispatch maps (<c>deferment.md</c> G-5, G-6).
/// Both were already testable structures — unlike the two Razor <c>@switch</c> blocks that needed
/// extracting first — so nothing but the tests was missing.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverDispatchMapParityTests
{
/// <summary>
/// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to
/// the raw <c>TagConfigJson</c> fallback. Empty — every editor-backed driver now has typed columns.
/// <para>Deliberately expressed as "has a typed editor ⇒ has typed CSV columns" rather than
/// "every driver type", because a driver with no typed editor has nothing to project into columns.
/// Tying the two maps together is what makes this catch the next omission.</para>
/// </summary>
[Fact]
public void Every_driver_with_a_typed_tag_editor_has_typed_csv_columns()
{
var missing = DriverTypeNames.All
.Where(t => TagConfigEditorMap.Resolve(t) is not null && CsvColumnMap.Resolve(t) is null)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these driver types have a typed tag editor but no CsvColumnMap entry, so CSV import/export "
+ $"drops them to the raw TagConfigJson fallback: {string.Join(", ", missing)}");
}
/// <summary>A CSV map for a driver type that no longer exists is a stale entry.</summary>
[Fact]
public void Every_csv_map_targets_a_declared_driver_type()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stale = CsvColumnMap.All
.Select(m => m.DriverType)
.Where(t => !declared.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
stale.ShouldBeEmpty($"CsvColumnMap has entries for undeclared driver types: {string.Join(", ", stale)}");
}
/// <summary>
/// Every driver type either produces a TYPED blob from browse-commit, or is on the documented
/// non-browsable list. The generic <c>{"address": …}</c> fallback opens EMPTY in a typed editor and
/// blanks the field on save — the MTConnect branch was added for exactly that, and Sql had the same
/// bug unnoticed (<c>deferment.md</c> G-6) because the fallback's comment claimed "browsable drivers
/// are all handled above", which stopped being true the moment <c>SqlDriverBrowser</c> registered.
/// <para><b>Asserted in both directions on purpose.</b> A one-way check would let a newly-browsable
/// driver be quietly added to the exclusion list instead of getting a branch. Requiring the two sets
/// to match exactly means a driver gaining or losing a browser forces this list to be revisited.</para>
/// </summary>
[Fact]
public void Browse_commit_falls_back_to_the_generic_address_key_for_exactly_the_non_browsable_drivers()
{
// Address fields as a browser supplies them; a driver whose browser states none must still produce
// something its typed editor can read from the leaf name alone.
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = "dbo",
["table"] = "Readings",
["columnName"] = "Speed",
};
var fellBack = DriverTypeNames.All
.Where(t => RawBrowseCommitMapper.BuildTagConfig(t, "Speed", addressFields) == """{"address":"Speed"}""")
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
fellBack.ShouldBe(
NonBrowsableDriverTypes.OrderBy(t => t, StringComparer.Ordinal).ToList(),
$"the set of driver types committing to the generic \"address\" key drifted. Extra entries are "
+ $"browsable drivers whose typed editor will open EMPTY and blank the field on save; missing "
+ $"entries gained a typed branch and should leave this list. Got: {string.Join(", ", fellBack)}");
}
/// <summary>
/// Driver types with no browser, for which the generic <c>address</c> key is the correct commit.
/// <list type="bullet">
/// <item><b>Modbus</b> — register addresses are numeric coordinates, not a browsable namespace.</item>
/// <item><b>Calculation</b> — a pseudo-driver over other tags' RawPaths; there is no device.</item>
/// </list>
/// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead.
/// </summary>
private static readonly string[] NonBrowsableDriverTypes =
[
DriverTypeNames.Modbus,
DriverTypeNames.Calculation,
];
}
@@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class TagConfigDriverTypeNameGuardTests
{
// Every DriverTypeNames constant that has a typed editor registered.
public static TheoryData<string> EditorMappedDriverTypes() =>
[
DriverTypeNames.Modbus,
DriverTypeNames.S7,
DriverTypeNames.AbCip,
DriverTypeNames.AbLegacy,
DriverTypeNames.TwinCAT,
DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient,
DriverTypeNames.Calculation,
DriverTypeNames.MTConnect,
];
//
// Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded
// against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared
// here, so the guard stayed green while the map went unmapped — and it had already silently drifted,
// omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real.
public static TheoryData<string> EditorMappedDriverTypes()
{
var data = new TheoryData<string>();
foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null))
{
data.Add(t);
}
return data;
}
/// <summary>
/// Coverage direction: every declared driver type has a typed tag editor, except those documented as
/// having none. Without this, enumerating the map above proves only that the map agrees with itself.
/// </summary>
[Fact]
public void Every_declared_driver_type_has_a_typed_tag_editor_or_is_documented_as_raw_json()
{
// Galaxy authors its tag reference as a dotted FullName through the Galaxy address picker rather
// than a typed field editor, so it uses the generic raw-TagConfig-JSON textarea by design.
string[] rawJsonByDesign = [DriverTypeNames.Galaxy];
var missing = DriverTypeNames.All
.Where(t => TagConfigEditorMap.Resolve(t) is null)
.Except(rawJsonByDesign, StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these driver types have no typed tag editor and are not documented as raw-JSON by design, so "
+ $"the /uns TagModal falls back to a bare JSON textarea for them: {string.Join(", ", missing)}");
}
[Theory]
[MemberData(nameof(EditorMappedDriverTypes))]
@@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
applier.MaterialiseDiscoveredNodes(
"eq-1",
Array.Empty<DiscoveredFolder>(),
new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) })
.ShouldBe(0);
}
// ---------------- fixtures ----------------
@@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class AddressSpaceApplierTests
{
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
[Fact]
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(EmptyPlan);
outcome.RebuildCalled.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(0);
outcome.RemovedNodes.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(0);
sink.RebuildCalls.ShouldBe(0);
sink.AlarmWrites.ShouldBeEmpty();
}
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
[Fact]
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = WithEquipmentRemoval("eq-1", "eq-2");
var outcome = applier.Apply(plan);
outcome.RemovedNodes.ShouldBe(2);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
// Each equipment torn down as a subtree.
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
}
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
[Fact]
public void Added_equipment_is_pure_add_and_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = new AddressSpacePlan(
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
outcome.AddedNodes.ShouldBe(1);
sink.AlarmWrites.ShouldBeEmpty();
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
[Fact]
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = new AddressSpacePlan(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: new[]
{
new AddressSpacePlan.DriverDelta(
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
},
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
[Fact]
public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
{
var sink = new ThrowingSink(throwOnAlarmWrite: true);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = WithEquipmentRemoval("eq-1");
var outcome = applier.Apply(plan); // should not throw
outcome.RemovedNodes.ShouldBe(1);
outcome.RebuildCalled.ShouldBeTrue();
}
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
/// folder (decision #4).</summary>
[Fact]
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
}
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
/// folder (not the namespace root), with the variable parented to that sub-folder and a
/// folder-scoped NodeId.</summary>
[Fact]
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// A Read plan threads Writable: false (the node stays CurrentRead).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
}
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
/// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
/// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
[Fact]
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
}
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag (<c>Alarm is not null</c>) materialises a
/// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
/// variable; a plain tag (<c>Alarm == null</c>) stays a value variable. The alarm tag's condition
/// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's
/// AlarmType/Severity. Proves BOTH branches in one composition.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
},
};
applier.MaterialiseEquipmentTags(composition);
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
// matching display/type/severity) and did NOT drive EnsureVariable.
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// A native equipment-tag alarm: the call-site threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
}
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its
/// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder),
/// using the folder-scoped NodeId.</summary>
[Fact]
public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)),
},
};
applier.MaterialiseEquipmentTags(composition);
// The sub-folder is still created for an alarm tag with a FolderPath.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
sink.VariableCalls.ShouldBeEmpty();
}
/// <summary>Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it
/// to <c>EnsureVariable</c>: a historized tag with NO override falls back to its <c>FullName</c>; a
/// historized tag WITH an override passes the override verbatim; a non-historized tag passes null.</summary>
[Fact]
public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
// Historized, no override ⇒ tagname defaults to FullName ("T.A").
new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float",
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null),
// Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName.
new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float",
FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"),
// Not historized ⇒ tagname is null.
new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float",
FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null),
},
};
applier.MaterialiseEquipmentTags(composition);
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
}
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
/// <c>FullName</c> (the resolve uses <c>string.IsNullOrWhiteSpace</c>, not just null).</summary>
[Fact]
public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "),
},
};
applier.MaterialiseEquipmentTags(composition);
var call = sink.HistorianCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.HistorianTagname.ShouldBe("40001");
}
/// <summary>Array-support Task 2 — an <see cref="EquipmentTagPlan"/> with <c>IsArray: true,
/// ArrayLength: 16</c> flowing through <see cref="AddressSpaceApplier.MaterialiseEquipmentTags"/> must
/// forward BOTH flags verbatim to the sink's <c>EnsureVariable</c>. Guards against arg-order swaps or
/// accidental drops in the wire-through.</summary>
[Fact]
public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u),
},
};
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
call.IsArray.ShouldBeTrue();
call.ArrayLength.ShouldBe(16u);
}
/// <summary>Array-support Task 2 — a scalar <see cref="EquipmentTagPlan"/> (<c>IsArray: false</c>,
/// <c>ArrayLength: null</c>) must pass <c>isArray == false</c> through to the sink. Guards against a
/// default flip that would silently materialise scalar tags as 1-D arrays.</summary>
[Fact]
public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.IsArray.ShouldBeFalse();
call.ArrayLength.ShouldBeNull();
}
/// <summary>Review M-1 — an array equipment tag authored with <c>Writable: true</c> must be
/// materialised as READ-ONLY (<c>writable == false</c>) because array writes are out of scope
/// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
/// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
/// writable path for arrays.</summary>
[Fact]
public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
// Authored ReadWrite AND IsArray — the applier must clamp to read-only.
new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
},
};
applier.MaterialiseEquipmentTags(composition);
// writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeTrue();
arrCall.ArrayLength.ShouldBe(8u);
}
/// <summary>Review M-1 regression — a scalar tag authored with <c>Writable: true</c> must still
/// be materialised as read/write (<c>writable == true</c>). The array-clamp must NOT affect
/// scalar tags.</summary>
[Fact]
public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
// Authored ReadWrite, scalar — must pass through writable: true unchanged.
new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
},
};
applier.MaterialiseEquipmentTags(composition);
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeFalse();
}
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
/// the equipment folder (no sub-folder when FolderPath is empty).</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
},
};
applier.MaterialiseEquipmentVirtualTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
// VirtualTags are computed outputs — always read-only (Writable: false).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
}
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
/// shared helper fails here.</summary>
[Fact]
public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64",
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
},
};
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentVirtualTags(composition);
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
}
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
},
};
applier.MaterialiseEquipmentVirtualTags(composition);
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
}
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
/// disabled alarms.</summary>
[Fact]
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentScriptedAlarms = new[]
{
new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
},
};
applier.MaterialiseScriptedAlarms(composition);
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
// A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap).
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
}
/// <summary>Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
/// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
/// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
/// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
/// parent-first before ensuring (a child folder's parent must exist first).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Child folder listed BEFORE its parent — the applier must re-order parent-first.
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
// Folders ensured parent-first regardless of input order (shallowest depth first).
sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
// Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
sink.VariableCalls.ShouldHaveSingleItem()
.ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
// Exactly one NodeAdded model-change, announced under the equipment root.
sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
}
/// <summary>Task 4 — a discovered array variable (rare) authored <c>Writable: true</c> is forced
/// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
/// IsArray / ArrayLength flags are forwarded verbatim to the sink.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
Writable: true, IsArray: true, ArrayLength: 8u),
};
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), variables);
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeTrue();
arrCall.ArrayLength.ShouldBe(8u);
}
/// <summary>Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
/// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
/// existing nodes), and a model-change is raised once PER call (twice across two calls).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
// Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
// DISTINCT set the applier produces is stable across re-applies.
sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
// One model-change per call ⇒ two across two calls.
sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
}
/// <summary>Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
/// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), Array.Empty<DiscoveredVariable>());
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.ShouldBeEmpty();
sink.ModelChangeCalls.ShouldBeEmpty();
}
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
@@ -1,44 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class CapturingAddressSpaceBuilderTests
{
[Fact]
public void Records_nested_path_segments_full_reference_and_metadata()
{
var b = new CapturingAddressSpaceBuilder();
var focas = b.Folder("FOCAS", "FOCAS");
var device = focas.Folder("10.0.0.5:8193", "cnc");
var identity = device.Folder("Identity", "Identity");
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
b.Nodes.Count.ShouldBe(1);
var n = b.Nodes[0];
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
n.BrowseName.ShouldBe("SeriesNumber");
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
n.DataType.ShouldBe(DriverDataType.String);
n.Writable.ShouldBeFalse(); // ViewOnly -> read-only
}
[Fact]
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
{
var b = new CapturingAddressSpaceBuilder();
var f = b.Folder("FOCAS", "FOCAS");
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
SecurityClassification.ViewOnly, false, IsAlarm: true));
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
b.Nodes.Count.ShouldBe(1);
}
}
@@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
public const string DiscoveryInjectionDormantV3 =
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
"re-migrating injection onto the raw device subtree is a separate follow-up.";
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
// (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped.
// Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a
// re-browse prompt instead (DriverInstanceActorRediscoverySignalTests).
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =

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