diff --git a/docs/AddressSpace.md b/docs/AddressSpace.md index fd208f0f..4dd9bd89 100644 --- a/docs/AddressSpace.md +++ b/docs/AddressSpace.md @@ -1,96 +1,84 @@ # 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=`) and `https://zb.com/otopcua/uns` -> (`s=///`), 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`. +The server serves **two OPC UA namespaces** under one shared root folder. Identity is owned by +`V3NodeIds` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`) and `AddressSpaceRealm`; the tree is +built from the deploy artifact (see `docs/IncrementalSync.md`), not by driver discovery at runtime. -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. +| Realm | Namespace URI | Subtree | NodeId `s=` identifier | +|---|---|---|---| +| `Raw` | `https://zb.com/otopcua/raw` | `/raw` device tree: Folder → Driver → Device → TagGroup → Tag | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) | +| `Uns` | `https://zb.com/otopcua/uns` | UNS tree: Area → Line → Equipment → signal | slash-joined `Area/Line/Equipment[/EffectiveName]` | + +`OtOpcUaNodeManager` registers both URIs (raw is index `NamespaceIndexes[0]`, UNS `[1]`). The retired +single namespace `https://zb.com/otopcua/ns` and the `EquipmentNodeIds` scheme no longer exist. The realm +travels beside the identifier at every sink seam (`IOpcUaAddressSpaceSink`); it is never parsed out of the +id string. Authoring and semantics: `docs/Raw.md`, `docs/Uns.md`. ## Root folder -`OtOpcUaNodeManager.CreateAddressSpace` creates a single shared root `FolderState` (`NodeId = OtOpcUa`, `BrowseName = OtOpcUa`, `EventNotifier = None`) under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. Every driver's folders and variables hang beneath this one root; the server is published under a single `ApplicationUri = urn:OtOpcUa` (the `OpcUaApplicationHostOptions.ApplicationUri` default) and all nodes live in the server's single custom namespace, not a per-driver `urn:OtOpcUa:{DriverInstanceId}`. The UNS Area → Line → Equipment folder skeleton under the root is materialised by `Phase7Applier.MaterialiseHierarchy` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs`); SystemPlatform (Galaxy) tags are materialised by `Phase7Applier.MaterialiseGalaxyTags`. - -## IAddressSpaceBuilder surface - -`IAddressSpaceBuilder` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs`) offers three calls: - -- `Folder(browseName, displayName)` — creates a child `FolderState` and returns a child builder scoped to it. -- `Variable(browseName, displayName, DriverAttributeInfo attributeInfo)` — creates a `BaseDataVariableState` and returns an `IVariableHandle` the driver keeps for alarm wiring. -- `AddProperty(browseName, DriverDataType, value)` — attaches a `PropertyState` for static metadata (e.g. equipment identification fields). - -Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager.BuildAddressSpaceAsync` calls `DiscoverAsync` once on startup and once per rediscovery cycle, tearing down the previous alarm subscription and clearing its sink registry before each re-walk so a redeploy doesn't double-fire alarm events. - -## DriverAttributeInfo → OPC UA variable - -Each variable carries a `DriverAttributeInfo` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`): - -| Field | Role | -|---|---| -| `FullName` | driver-side full reference used as the lookup key for Read/Write/Subscribe; also seeds the variable's string `NodeId` | -| `DriverDataType` | resolved to a built-in `DataTypeIds.*` NodeId at materialization time — `OtOpcUaNodeManager.ResolveBuiltInDataType` maps the data-type name string; each driver first maps its native type into `DriverDataType` (e.g. Galaxy via `Browse/DataTypeMap.Map`) | -| `IsArray` / `ArrayDim` | declared 1-D-array length carried as metadata; the Galaxy discoverer sets `ArrayDim` only when the gateway reports a positive dimension | -| `SecurityClass` | write-authorization tier (`SecurityClassification`); enforced server-side by the `NodePermissions` ACL evaluator (`TriePermissionEvaluator`) mapping each `OpcUaOperation` to a required permission bit. The Galaxy driver also caches it per full reference (`_securityByFullRef`) to answer `GetSecurityClassification` | -| `IsHistorized` | marks the attribute as feeding historian / HistoryRead | -| `IsAlarm` | drives the `MarkAsAlarmCondition` pass (see below) | -| `WriteIdempotent` | when true the attribute's writes are safe to replay, so the capability invoker may apply Polly retry; defaults false so pulses / acks / counters aren't auto-retried | -| `Source` | `NodeSourceKind` discriminator (`Driver` / `Virtual` / `ScriptedAlarm`) that decides which subsystem dispatches the node's Read/Write/Subscribe | - -The variable is created with `StatusCode = BadWaitingForInitialData` and a null value until the first Read or `ISubscribable.OnDataChange` push lands. Note the production SDK sink (`OtOpcUaNodeManager.EnsureVariable`) currently materialises every variable as `ValueRank = Scalar`, read-only `AccessLevel`, and `Historizing = false` — the `IsArray`/`IsHistorized` intent lives in `DriverAttributeInfo` but is not yet projected onto the SDK node. - -## CapturingBuilder + alarm sink registration - -`GenericDriverNodeManager.BuildAddressSpaceAsync` wraps the supplied builder in a `CapturingBuilder` before calling `DiscoverAsync`. The wrapper observes every `Variable()` call: when a returned `IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo)` fires, the sink is registered in the manager's `_alarmSinks` dictionary keyed by the variable's `FullReference`. Subsequent `IAlarmSource.OnAlarmEvent` pushes are routed to the matching sink by `SourceNodeId`. This keeps the alarm-wiring protocol declarative — drivers just flag `DriverAttributeInfo.IsAlarm = true` and the materialization of the OPC UA `AlarmConditionState` node is handled by the server layer. See `docs/AlarmTracking.md`. +`OtOpcUaNodeManager.CreateAddressSpace` creates **one** shared root `FolderState` — identifier and browse +name `OtOpcUa` (`V3NodeIds.SharedRootFolderId`), minted in the **raw** namespace — under the standard +`Objects` folder with an `Organizes` reference. Top-level Raw folders and top-level UNS Areas both parent +under it. The identifier is a display name, not a path segment; the data-plane authorizer maps it onto the +trie's zero-segment realm-root scope (`docs/security.md`). The application URI is +`OpcUaApplicationHostOptions.ApplicationUri` (default `urn:OtOpcUa`); there is no per-driver namespace. ## NodeId scheme -All nodes share the server's single custom namespace (`NamespaceIndex`); NodeIds are string identifiers, not numeric. The string values come from the source rows / driver references — there is no per-driver namespace prefix: +Segments are validated by `RawPaths.ValidateSegment` (no embedded `/`, no leading/trailing whitespace; +ordinal, case-sensitive). `V3NodeIds.Raw(rawPath)` is a pass-through; `V3NodeIds.Uns(...)` / +`UnsChild(parent, segment)` build UNS ids. -| Node type | NodeId (string identifier) | Example | -|---|---|---| -| Shared root | `OtOpcUa` | `OtOpcUa` | -| UNS Area / Line / Equipment folder | the Config-DB `UnsAreaId` / `UnsLineId` / `EquipmentId` | `EQ_Press_07` | -| Galaxy tag variable | the MXAccess reference (`Phase7Applier` uses `GalaxyTagPlan.MxAccessRef`) | `DelmiaReceiver_001.DownloadPath` | -| Equipment tag variable | the driver full reference from `DriverAttributeInfo.FullName` | driver-specific | +| Node | Realm | `s=` identifier | Example | +|---|---|---|---| +| Shared root | Raw | `OtOpcUa` | `OtOpcUa` | +| Raw folder / driver / device / tag group / tag | Raw | its own RawPath | `Plant/Modbus/dev1/Speed` | +| UNS Area / Line / Equipment folder | Uns | `Area`, `Area/Line`, `Area/Line/Equipment` | `packaging/line-1/press-07` | +| UNS reference variable (`UnsTagReference`), virtual tag, scripted alarm | Uns | `Area/Line/Equipment/EffectiveName` | `packaging/line-1/press-07/speed` | -For Galaxy the variable `FullName` is the `tag_name.AttributeName` MXAccess reference; AB CIP uses `tag.Name` or `tag.Name.member` for UDT members; the shape is the driver's choice. Browse-path resolution (OPC UA `TranslateBrowsePathsToNodeIds`) is the canonical way clients map a browse path to one of these flat NodeIds. +Area, Line and Equipment names must match `^[a-z0-9-]{1,32}$` (`docs/Uns.md`). EffectiveNames are unique per +equipment across references, virtual tags and scripted alarms. -## Per-driver hierarchy examples +## One source, fanned to both namespaces -- **Galaxy**: `GalaxyDriver.DiscoverAsync` delegates to `GalaxyDiscoverer` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs`), which walks the hierarchy from `IGalaxyHierarchySource` — one folder per Galaxy object (browse name = `contained_name`, falling back to `tag_name`), one variable per dynamic attribute (full reference = `tag_name.AttributeName`). It copies the gateway-supplied `IsAlarm` flag through to `DriverAttributeInfo` and, for alarm-bearing attributes, calls `MarkAsAlarmCondition` with the five sub-attribute refs built by `AlarmRefBuilder`. -- **Modbus**: streams one folder per device, one variable per register range from `ModbusDriverOptions`. No alarm surface. -- **AB CIP**: `AbCipDriver.DiscoverAsync` emits an `AbCip` root, then a folder per configured device. Pre-declared tags become variables under the device folder; UDT (`Structure`) tags fan out into a sub-folder with one variable per member; when controller browse is enabled, `IAbCipTagEnumerator` adds discovered tags under a `Discovered/` sub-folder. (`AbCipTemplateCache` caches UDT layouts for the libplctag enumerator.) -- **OPC UA Client**: re-exposes a remote server's address space — `OpcUaClientDriver.DiscoverAsync` browses the upstream from `BrowseRoot` into a `Remote` folder (pass 1), then batch-reads DataType/AccessLevel/ValueRank/Historizing per variable before registering them (pass 2). +Every device value has exactly one source: the raw tag's node, keyed by RawPath. A `UnsTagReference` +projects that raw tag into an equipment as a UNS variable that carries an `Organizes` reference to its raw +node and mirrors it. `DriverHostActor` fans one driver publish for a RawPath to the raw NodeId **and** every +referencing UNS NodeId with identical value, quality and timestamp. Writes via either NodeId route to the +same driver reference under the same realm-qualified `WriteOperate` gate. Native alarms materialise once at +the raw tag (`ConditionId = RawPath`) and fan through notifiers to the raw device folder and every +referencing equipment folder. Both NodeIds register the same historian tagname. -See `docs/v2/driver-specs.md` for the per-driver discovery contracts. +## Materialisation -## Rediscovery +`AddressSpaceComposer` turns the deployment artifact into an `AddressSpaceComposition`; +`AddressSpaceApplier` (both in `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) walks it (`MaterialiseHierarchy`, +`MaterialiseRawSubtree`, `MaterialiseUnsReferences`, `MaterialiseEquipmentVirtualTags`, +`MaterialiseScriptedAlarms`, ...) into the write-only `IOpcUaAddressSpaceSink`, whose production binding +`SdkAddressSpaceSink` forwards to `OtOpcUaNodeManager`, the `CustomNodeManager2` that owns the +`FolderState` / `BaseDataVariableState` instances. Variables are created through `EnsureVariable`: +`ValueRank` is `OneDimension` with `ArrayDimensions=[arrayLength]` for array tags and `Scalar` otherwise; +`AccessLevel` carries the write bit only for writable nodes (array nodes only when the driver can write the +array); `Historizing` plus the `HistoryRead` bit are set when a historian tagname is supplied. -Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change. Galaxy's `DeployWatcher` raises it when the observed `time_of_last_deploy` advances; TwinCAT raises it on the ADS symbol-version-changed signal (`DeviceSymbolVersionInvalid`, error 1809). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Drivers that don't implement `IRediscoverable` (Modbus, S7, OPC UA Client) only change their address space when a new generation is published from the Config DB. +The nodes start `BadWaitingForInitialData` until the first read or subscription push. Datatype names resolve +through `OtOpcUaNodeManager.ResolveBuiltInDataType`; each driver first maps its native type into +`DriverDataType` (Galaxy: `Driver.Galaxy/Browse/DataTypeMap.cs`). + +## Driver discovery is the browse picker, not the address space + +`ITagDiscovery.DiscoverAsync` (streaming into `IAddressSpaceBuilder`: `Folder` / `Variable` / +`AddProperty`, each variable described by a `DriverAttributeInfo`) feeds the `/raw` browse-commit flow via +`Commons/Browsing/DiscoveryDriverBrowser`: the operator browses a device, picks leaves and commits them as +raw tags, which then reach the server as an ordinary deployment. Drivers that signal backend change via +`IRediscoverable` only raise an operator "re-browse" prompt — the served tree never changes on its own. +See `docs/IncrementalSync.md`. `GenericDriverNodeManager` (`Core/OpcUa/`) is leftover scaffolding with no +production caller. ## Key source files -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — orchestration + `CapturingBuilder` -- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — OPC UA materialization (write-only sink fed by the actor system) -- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — materialises the UNS folder hierarchy + Galaxy tags into the sink -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` — walks Config-DB Equipment-namespace rows into the builder -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs` — builder contract -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` — driver discovery capability -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor +- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`, `AddressSpaceRealm.cs` — identity authority +- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — RawPath/segment rules +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `SdkAddressSpaceSink.cs` — SDK materialisation +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpaceApplier.cs` — compose and apply +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`, `IAddressSpaceBuilder.cs`, `DriverAttributeInfo.cs` — discovery-side contracts diff --git a/docs/IncrementalSync.md b/docs/IncrementalSync.md index 6b3e4606..deb6be2f 100644 --- a/docs/IncrementalSync.md +++ b/docs/IncrementalSync.md @@ -1,90 +1,71 @@ # 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. +How a change reaches the running server's address space in v3. There is exactly one path that +changes the served address space — the **deploy artifact** — and one advisory signal +(`IRediscoverable`) that never changes it. -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. +## The deploy-artifact path -## Driver-backend rediscovery — IRediscoverable +1. **Compose.** `ConfigComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`) + snapshots the whole Config DB into one gzip-compressed JSON blob (`Deployment.ArtifactBlob`) whose + `RevisionHash` is the SHA-256 of those exact bytes. A deployment is fleet-wide; each node + self-scopes the artifact (`DeploymentArtifact.ResolveClusterScope`). +2. **Dispatch.** `ConfigPublishCoordinator` (`.../ControlPlane/Coordinators/`) sends each enabled + `ClusterNode` a payload-free dispatch and waits for acks until the apply deadline. A node obtains the + bytes either straight from central SQL (`ConfigSource:Mode=Direct`) or over gRPC with a SHA-256 + check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`. +3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and + reconciles in two halves: + - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed + driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`; + a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the + running children are left alone (never an empty configuration). + - **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact; + `AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier` + picks the delta class; `AddressSpaceApplier` + (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) mutates `OtOpcUaNodeManager` through the sink. +4. **Classes** (`AddressSpaceChangeKind`): `Empty`, `AttributeOnly`, `PureAdd`, `PureRemove`, + `AddRemoveMix` — all applied surgically, so unrelated MonitoredItems survive — and `Rebuild`, the + default-closed fallback for anything the classifier does not whitelist. Any surgical `false`/throw + also ratchets to a safe rebuild, and a degraded non-rebuild apply does not advance the last-applied + composition (the next deploy re-diffs and re-ensures). The whitelist, the UNS-mirror repair rule and + the add/remove announcements are described in the "surgical deploys" paragraph of `CLAUDE.md` + and `docs/plans/2026-08-15-surgical-deploys.md`. -Drivers whose backend has a native change signal implement `IRediscoverable` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`): +Virtual tags and scripted alarms ride the same artifact and the same plan; a script edit is just another +changed record in the composition (see `docs/VirtualTags.md`, `docs/ScriptedAlarms.md`). -```csharp -public interface IRediscoverable -{ - event EventHandler? OnRediscoveryNeeded; -} -public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint); -``` +## Driver-backend rediscovery is advisory -The driver fires the event with a reason string (for the diagnostic log) and an optional scope hint — a non-null hint lets Core scope the rebuild surgically to that subtree; null means "the whole address space may have changed". +Drivers whose backend signals a tag-set change implement `IRediscoverable` +(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`; `OnRediscoveryNeeded` carries a +`Reason` and an optional `ScopeHint`). Raisers today: **Galaxy** (`DeployWatcher`, new +`time_of_last_deploy`), **TwinCAT** (ADS symbol-version-changed), **MQTT/Sparkplug** and **MTConnect**. +AbCip, AbLegacy, FOCAS, Modbus, S7 and the rest raise nothing. -Drivers that implement the capability today: +`DriverInstanceActor` is the only production consumer: it subscribes in `PreStart`, detaches in +`PostStop`, and folds the raise into the driver-health snapshot +(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`), which the AdminUI `/hosts` page shows +as a **re-browse** chip. **It never rebuilds or edits the served address space** — v3 raw tags are authored +explicitly in `/raw`, so an operator re-browses the device and commits, and the result reaches the +server as an ordinary deployment. `ScopeHint` is not read by the actor. (The older runtime +injection path was removed, #507 closed as superseded.) -- **Galaxy** — `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) subscribes to the mxaccessgw gRPC stream (`IGalaxyDeployWatchSource.WatchAsync`) and fires on a new `time_of_last_deploy` value. The gateway polls the Galaxy repository DB internally; the driver side is event-driven. -- **TwinCAT** — observes ADS symbol-version-changed notifications (ADS error `DeviceSymbolVersionInvalid`, decimal 1809 / `0x0711`). Note: legacy Beckhoff documentation sometimes cites `0x0702` (`DeviceInvalidGroup`) — that is a transcription error; the correct code is `0x0711` per `TwinCATStatusMapper.AdsSymbolVersionChanged` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs:35`). +## Not part of the v3 path -Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRediscoverable` — their tags only change when a new generation is published from the Config DB. Core sees absence of the interface and skips change-detection wiring for those drivers (decision #54). - -## Config-DB generation publishes - -Tag-set changes authored in the Admin UI (UNS edits, CSV imports, driver-config edits) accumulate in a draft generation and commit via `sp_PublishGeneration`. The delta between the currently-published generation and the proposed next one is computed by `sp_ComputeGenerationDiff`, which drives: - -- The publish-preview surface in the Admin UI (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor`, backed by `AdminOperationsClient`) so operators can preview what will change before clicking Publish. -- The 409-on-stale-draft flow (decision #161) — a UNS drag-reorder preview carries a `DraftRevisionToken` so Confirm returns `409 Conflict / refresh-required` if the draft advanced between preview and commit. - -After publish, the server's generation applier invokes `IDriver.ReinitializeAsync(driverConfigJson, ct)` on every driver whose `DriverInstance.DriverConfig` row changed in the new generation. Reinitialize is the in-process recovery path for Tier A/B drivers; if it fails the driver is marked `DriverState.Faulted` and its nodes go Bad quality — but the server process stays running. See `docs/v2/driver-stability.md`. - -Drivers whose discovery depends on Config DB state (Modbus register maps, S7 DBs, AB CIP tag lists) re-run their discovery inside `ReinitializeAsync`; Core then diffs the new node set against the current address space. - -## Rebuild flow - -When a rediscovery is triggered (by either source), `GenericDriverNodeManager` re-runs `ITagDiscovery.DiscoverAsync` into the same `CapturingBuilder` it used at first build. The new node set is diffed against the current: - -1. **Diff** — full-name comparison of the new `DriverAttributeInfo` set against the existing `_variablesByFullRef` map. Added / removed / modified references are partitioned. -2. **Snapshot subscriptions** — before teardown, Core captures the current monitored-item ref-counts for every affected reference so subscriptions can be replayed after rebuild. -3. **Teardown** — removed / modified variable nodes are deleted via `CustomNodeManager2.DeleteNode`. Driver-side subscriptions for those references are unwound via `ISubscribable.UnsubscribeAsync`. -4. **Rebuild** — added / modified references get fresh `BaseDataVariableState` nodes via the standard `IAddressSpaceBuilder.Variable(...)` path. Alarm-flagged references re-register their `IAlarmConditionSink` through `CapturingBuilder`. -5. **Restore subscriptions** — for every captured reference that still exists after rebuild, Core re-opens the driver subscription and restores the original ref-count. - -Exceptions during teardown are swallowed per decision #12 — a driver throw must not leave the node tree half-deleted. - -## Scope hint - -When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree. Null scope falls back to a full-tree diff. - -## Virtual tags in the rebuild - -Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), virtual (scripted) tags live in the same address space as driver tags and flow through the same rebuild. `EquipmentNodeWalker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs`) emits virtual-tag children alongside driver-tag children with `DriverAttributeInfo.Source = NodeSourceKind.Virtual`, and `DriverNodeManager` registers each variable's source in `_sourceByFullRef` so the dispatch branches correctly after rebuild. Virtual-tag script changes published from the Admin UI land through the same generation-publish path — the `VirtualTagEngine` recompiles its script bundle when its config row changes and `DriverNodeManager` re-registers any added/removed virtual variables through the standard diff path. Subscription restoration after rebuild runs through each source's `ISubscribable` — either the driver's or `VirtualTagSource` — without special-casing. - -## Active subscriptions survive rebuild - -Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop. +- `sp_PublishGeneration` / `sp_ComputeGenerationDiff` are stubs that `RAISERROR` in the v3 baseline + migration; there is no generation-publish flow and no `DraftRevisionToken`. +- `GenericDriverNodeManager` (`Core/OpcUa/`) is retained scaffolding with no production caller; the + diff / teardown / subscription-replay "rebuild flow" described by earlier versions of this page does not + exist in `src/`. ## Key source files -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs` — backend-change capability -- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — discovery orchestration -- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` — `ReinitializeAsync` contract -- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — publish-flow driver -- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` — cluster singleton invoked by the Admin UI's `AdminOperationsClient` -- `docs/v2/config-db-schema.md` — `sp_PublishGeneration` + `sp_ComputeGenerationDiff` -- `docs/v2/admin-ui.md` — DiffViewer + draft-revision-token flow +- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs` — artifact composition + hash +- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — dispatch + ack set +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — apply + driver reconcile +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — node-side artifact parsing +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpacePlan.cs`, + `AddressSpaceChangeClassifier.cs`, `AddressSpaceApplier.cs` — compose, diff, classify, apply +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` — `IRediscoverable` consumer +- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs` — in-place update seam diff --git a/docs/drivers/Modbus.md b/docs/drivers/Modbus.md index 1f9e7262..7fbb7185 100644 --- a/docs/drivers/Modbus.md +++ b/docs/drivers/Modbus.md @@ -96,14 +96,15 @@ to the actual offending register(s). Per-tag escape hatch: from the driver's `DriverConfig` JSON. Key fields: - **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology. +- **`Transport`** — `Tcp` (default; MBAP header + transaction id) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled over the socket to a serial-to-Ethernet gateway); string-valued, absent = `Tcp`. The Test Connect probe follows the authored mode. - **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space. - **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0). - **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing. - **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes. - **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression. -Full per-field descriptions live in `ModbusDriverOptions.cs`. The JSON skeleton -is reproduced in [docs/v2/driver-specs.md §2](../v2/driver-specs.md). +Full per-field descriptions live in `ModbusDriverOptions.cs`. The v2-era JSON skeleton +in [docs/v2/driver-specs.md §2](../v2/driver-specs.md) predates the `Transport` key and the v3 raw-tag model. ## Testing diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 9982505f..3a708051 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -103,7 +103,7 @@ Three properties worth knowing: - **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide: - [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).) -- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path; that file is the authoritative per-driver reference. +- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path and are the current reference; that file is the v2-era per-field spec (see its status banner for which drivers it covers). ## Test-fixture coverage maps @@ -114,7 +114,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni - [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102` - [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those - [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness -- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped +- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; FOCAS runs in-process (Tier A) — the Tier C out-of-process isolation once scoped was never built - [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session` - [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one - [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive) @@ -125,5 +125,5 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni - [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`. - [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source. - [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`. -- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules. +- [docs/v2/driver-stability.md](../v2/driver-stability.md) — historical design record of the A / B / C tier model. Only `DriverTier` + the `CapabilityPolicy` defaults per tier × capability (`DriverResilienceOptions.GetTierDefaults`) are live; its `MemoryTracking` / recycle / out-of-process isolation machinery was deleted (#522) and every driver runs Tier A. - [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy. diff --git a/docs/operations/2026-07-16-secrets-clustered-master-key.md b/docs/operations/2026-07-16-secrets-clustered-master-key.md index 5b1d9e9f..c62343d0 100644 --- a/docs/operations/2026-07-16-secrets-clustered-master-key.md +++ b/docs/operations/2026-07-16-secrets-clustered-master-key.md @@ -31,35 +31,90 @@ plaintext secret on every node, no matter its role: combination — must unwrap the store with the exact same master key. A per-node KEK (e.g. a per-box DPAPI-protected key) would make each node decrypt every *other* node's ciphertext rows to garbage. -2. **Identical store rows on every node.** All nodes must read the same SQLite - database (same file, or a replicated/shared copy with the same rows) — not - independently-seeded stores that happen to share a KEK. +2. **The same store rows on every node of a pair.** Nodes must read the same rows — + not independently-seeded stores that merely share a KEK. Either mount one SQLite file + (see "Alternative: shared volume" below) or, preferred, turn on Akka secret replication + so each pair converges its own rows. -`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator` -— there is no built-in cross-node replication today. Meeting both requirements in -production is a deployment concern, covered below. +The base `ZB.MOM.WW.Secrets` store is a plain local SQLite `ISecretStore` whose default +replicator is `NoOpSecretReplicator`: **with `Secrets:Replication:Enabled=false` (the +default) nothing replicates and each node's store is private.** That is what this page +used to describe as the only option. It is no longer: pair replication exists +(`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet`, lib pin `0.6.2` in `Directory.Packages.props`; +gated by `SecretsRegistration`, see below). -## Recommended interim posture (G-5) +## Recommended posture: pair-local Akka replication -Until real replication exists (G-7, below), the recommended production posture is: +Opt in with `Secrets:Replication:Enabled=true` on **every node** (default `false`, so absent +config means a plain local SQLite store). `SecretsRegistration.AddOtOpcUaSecrets` +(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs`) then registers +`AddZbSecretsAkkaReplication` **instead of** `AddZbSecrets` (the two are alternatives, never +additive) and a `SecretReplicationStarter` hosted service resolves the store at boot so a +node that never reads a secret still joins anti-entropy. Keys: `Secrets:Replication:Enabled`, +`:AnnounceInterval` (default `00:00:30`; each node publishes a name/revision/timestamp/tombstone +manifest per interval, no ciphertext), `:ActorName` (`zb-secret-replication`). -- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only - key file that is identical on every node of every role** — a base64-encoded - 32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed - to each node's filesystem/secret-mount by the deployment tooling, and **never - committed** to the repo. Treat it with the same discipline as any other - production secret (restrictive file ACLs, no logging, rotated via a future - KEK-rotation runbook — not yet built). -- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume** - that every node mounts (admin, driver, and dev/fused alike), so every node's - migrator opens and reads the same rows at boot. +- **Scope is PAIR-LOCAL.** Since mesh Phase 6 each application cluster is its own 2-node Akka + mesh and replication rides that mesh's DistributedPubSub, so a secret converges **within its + pair and nowhere else**. There is no fleet-wide sync (site nodes have no SQL hub). Seed each + pair independently: central via `/admin/secrets`, driver-only site pairs (no AdminUI) via the + secrets CLI against one node's store, or a ciphertext-only bundle import. See + `docs/Redundancy.md` §"Secrets replication is pair-local". +- **Both nodes of a pair must share the same KEK** — rows replicate verbatim (ciphertext, wrapped + DEK and `kek_id` untouched), never re-encrypted. +- **Deletes replicate** as soft tombstones (last-writer-wins on `updated_utc`/`revision`). +- **Re-wraps do not replicate** (a re-wrap preserves `revision`/`updated_utc`, so it is invisible to + last-writer-wins). KEK rotation therefore runs against each node's store — see the next section. +- The store should sit on a **persistent per-node path** (`Secrets:SqlitePath`, e.g. the node's + `/app/data` volume in containers); the relative default lands inside the image layer and is lost on + recreate. +- Evidence: live-gated PASS 4/4 on the three-mesh docker-dev rig (convergence <= 7 s, no cross-pair + leakage in any direction, delete < 1 s, clean restart without resurrection) — + `docs/plans/2026-08-07-secrets-pair-local-live-gate.md`. Replication stays default-off in the product. -Writes to the store are rare and human-driven — an operator using the -`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while -reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle, -regardless of role. The access pattern is read-mostly / effectively -single-writer, which is what makes a shared SQLite volume viable as an interim -posture (see caveat below). +Whichever store layout is used, the KEK is delivered per node as before: +**`Secrets:MasterKey:Source = File`** with `FilePath` pointing at a read-only base64 32-byte key +(`openssl rand -base64 32`), distributed identically to every node of the pair by deployment tooling and +**never committed**. Writes are rare and human-driven (`/admin/secrets` on admin nodes, or the +CLI); reads happen at every node's boot and each `ResolveCacheTtl` refresh. + +## Rotating the KEK + +Rotation re-wraps each row's per-secret DEK under the new KEK (`wrapped_dek`, `wrap_nonce`, +`wrap_tag`, `kek_id` change; the sealed body, `revision` and `updated_utc` do not). The +authoritative operator procedure and troubleshooting table live with the library: +`scadaproj/ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`, driven by the `secret rewrap-all` +CLI verb (in that repo; not packed). The OtOpcUa-specific points: + +1. **Back up every store first** (copy each node's SQLite file) — the backup is the complete undo. +2. **Stage the new KEK on every node of the pair** (same file path / env var as `Secrets:MasterKey` + resolves) but do not restart yet. Never pass key values on a command line; use + `--old-key-env VAR` / `--old-key-file PATH` and, if the new key is not the node's configured + provider, `--new-key-file`. +3. **Run `secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY` once per store, i.e. on EACH node.** + Because re-wraps do not replicate, rotating one node leaves its partner's rows on the old + `kek_id`. With replication on, do not leave the pair half-rotated: a node whose rows carry a KEK + it cannot unwrap fails closed (`Row wrapped by unknown KEK`) on resolve. +4. **Quiesce first**: stop (or drain) the node while its store is rewrapped. Rotating a pair + node-by-node while the other node keeps serving works for resolves, but do not author secrets + anywhere in the pair mid-rotation — a fresh write is wrapped under whichever KEK the writing node + currently uses and replicates verbatim to a partner that may not yet hold that KEK. +5. **Cut over**: confirm `Secrets:MasterKey` resolves the new key on both nodes, start them, and + confirm a `${secret:}` value resolves (the pre-host expander fails the boot loudly if not). +6. **Destroy the old key** only after both stores resolve cleanly. Re-running `rewrap-all` is + idempotent (`alreadyCurrent`), so an interrupted pass is resumed by running it again. +7. Rollback: restore the step-1 backups on both nodes and revert the KEK source. + +Pair rotation across **separate pairs** (central, site-a, site-b) is independent per pair; there is no +fleet-wide order. + +## Alternative: shared SQLite volume (no replication) + +The earlier interim posture — every node of a pair mounting one `Secrets:SqlitePath` on a shared +or replicated volume — still works for a deployment that keeps `Secrets:Replication:Enabled=false`: +reads dominate and writes are effectively single-writer. See the caveat below; it is not safe with +concurrent writers, and it is not available to DB-less driver-only site nodes that have no shared +volume with their partner. ## How it's delivered (do NOT commit these values) @@ -107,14 +162,14 @@ or key is missing. SQLite's file-locking model does not tolerate concurrent multi-writer access well over network filesystems (SMB/NFS locking is unreliable, and even on a clustered -block volume only one writer should be active at a time). The interim posture +block volume only one writer should be active at a time). The shared-volume alternative above is acceptable because: - Reads dominate (every node's boot + cache-refresh cycle, across every role). - Writes are rare, human-initiated, and effectively single-writer in practice (an operator runs the CLI/UI against one admin node at a time). -It is **not** a substitute for real replication, and it is not safe if multiple +It is **not** a substitute for real replication (Akka pair replication, above), and it is not safe if multiple nodes attempt concurrent writes. Do not build automation that writes secrets from more than one node simultaneously. @@ -128,31 +183,15 @@ ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryptio (KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption work — doing so risks invalidating active sessions for an unrelated reason. -It is, however, the model for where the *next* iteration of secret storage -should go — see the G-7 hand-off below. +## The G-7 hand-off (status) -## The G-7 hand-off - -The posture above is an interim, ops-only workaround. The long-term shape, -tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a -ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for -the Data Protection key ring: - -```csharp -services.AddDataProtection() - .PersistKeysToDbContext() - .SetApplicationName("OtOpcUa"); -``` - -(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`). -`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused -alike — a single MS SQL-backed source of truth for the Data Protection key ring; -the secret store is the natural next tenant of that same shared database instead -of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library -code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet, -overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not -built as part of this cut. This runbook's shared-SQLite-volume posture is the -bridge until G-7 lands. +G-7 (`scadaproj/components/secrets/GAPS.md`) asked for real cross-node replication. It was +built in two flavours in the library (`ZB.MOM.WW.Secrets.Replicator.SqlServer` and +`.AkkaDotNet`, plus a gRPC replicator package). **OtOpcUa production uses the Akka pair-local +option** (the decision recorded in scadaproj#3), wired by `SecretsRegistration` as described above; +the ConfigDb-backed store this page used to anticipate is not what shipped here. Driver-only nodes +have no ConfigDb since mesh Phase 4, which is a further reason a ConfigDb secret store would not +reach them. ## Dev/test/default posture (unchanged) diff --git a/docs/v2/driver-specs.md b/docs/v2/driver-specs.md index a56789d8..25c887f1 100644 --- a/docs/v2/driver-specs.md +++ b/docs/v2/driver-specs.md @@ -1,6 +1,22 @@ # OtOpcUa v2 — Driver Implementation Specifications -> **Status**: DRAFT — reference document for plan.md +> **Status (2026-10)**: v2-era DRAFT, **not authoritative**. It covers 8 of the 12 shipped drivers and +> several of its architecture claims are stale. The current per-driver references are the pages in +> [`docs/drivers/`](../drivers/README.md) (the README there lists all 12 and is the accurate overview). +> +> - **Not covered here at all:** MQTT/Sparkplug ([Mqtt.md](../drivers/Mqtt.md)), MTConnect +> ([MTConnect.md](../drivers/MTConnect.md)), Sql ([Sql.md](../drivers/Sql.md)), Calculation +> ([Calculation.md](../drivers/Calculation.md)). +> - **Hosting/tier claims are stale:** every driver runs in-process as Tier A (Galaxy reaches MXAccess over +> gRPC to `mxaccessgw`; FOCAS is a pure-managed in-process `FocasWireClient`, not a `Focas.Host` pipe +> service) — see `docs/v2/driver-stability.md`'s banner. The "SystemPlatform namespace kind" is retired: +> Galaxy is a standard Equipment-kind driver, and the address space is the dual Raw/UNS namespace +> ([../AddressSpace.md](../AddressSpace.md)). +> - **Modbus:** the driver has its own socket layer (no NModbus dependency) and a driver-level +> `transport` key — `Tcp` (default, MBAP framing) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled +> to a serial-to-Ethernet gateway); see [Modbus.md](../drivers/Modbus.md). +> - **AB CIP / AB Legacy:** a second, managed `Transport` (`Native` | `Libplctag`) exists — `CLAUDE.md` +> §"AB drivers: native transport" and the driver pages. > > **Created**: 2026-04-16 diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md index c5be713d..cab9791e 100644 --- a/docs/v2/driver-stability.md +++ b/docs/v2/driver-stability.md @@ -49,6 +49,10 @@ Driver stability needs to be a first-class architectural concern, not a per-driv Every driver is assigned to one of three tiers based on the trust level of its dependency stack: +> **Design intent, not the shipped assignment.** The per-driver tier tables below record the proposed +> assignment (Galaxy/FOCAS in Tier C, the Ab/S7/TwinCAT drivers in Tier B). As shipped every driver is Tier A — +> see the banner above and `docs/drivers/README.md`, which is the accurate reference. + ### Tier A — Pure Managed Drivers whose entire dependency chain is verifiable .NET. Standard exception handling and Polly are sufficient. Run in-process in the main server.