From cf03ca279da12cb1828cf07c059d0532c833b092 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:50:30 -0400 Subject: [PATCH] =?UTF-8?q?docs(v3):=20implementation=20plans=20=E2=80=94?= =?UTF-8?q?=20master=20orchestration=20+=204=20batch=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v3 design finalized: all 7 open items resolved (live historian probe: 255-char tagname limit; SDK multi-notifier spike: native AddNotifier, never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column dictionaries; ScadaBridge re-bind sized; rename-warning scan decided), reconciled with the pre-v3 universal discovery browser (its new §11 v3 forward note re-targets the browse commit contract). - Calculation pseudo-driver mini-design (IDependencyConsumer capability, mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity error semantics). - Implementation plans for Opus-agent execution, grounded against exact files/symbols: master plan (batch DAG, worktree-isolated parallel waves, contract-first fan-out, global gotcha list) + one plan per batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS reference-only+{{equip}}, 4 dual-namespace address space = v3.0), each with per-wave file ownership and a mandatory docker-dev live gate. --- ...26-07-15-calculation-driver-mini-design.md | 161 +++++++ ...026-07-15-raw-uns-two-subtree-v3-design.md | 430 +++++++++++++++--- ...7-15-universal-discovery-browser-design.md | 53 ++- ...26-07-15-v3-batch1-schema-identity-plan.md | 228 ++++++++++ ...07-15-v3-batch2-raw-ui-calculation-plan.md | 193 ++++++++ .../2026-07-15-v3-batch3-uns-rework-plan.md | 112 +++++ ...2026-07-15-v3-batch4-address-space-plan.md | 170 +++++++ .../2026-07-15-v3-implementation-plan.md | 162 +++++++ 8 files changed, 1449 insertions(+), 60 deletions(-) create mode 100644 docs/plans/2026-07-15-calculation-driver-mini-design.md create mode 100644 docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md create mode 100644 docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md create mode 100644 docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md create mode 100644 docs/plans/2026-07-15-v3-batch4-address-space-plan.md create mode 100644 docs/plans/2026-07-15-v3-implementation-plan.md diff --git a/docs/plans/2026-07-15-calculation-driver-mini-design.md b/docs/plans/2026-07-15-calculation-driver-mini-design.md new file mode 100644 index 00000000..61cabe75 --- /dev/null +++ b/docs/plans/2026-07-15-calculation-driver-mini-design.md @@ -0,0 +1,161 @@ +# `Calculation` pseudo-driver — mini-design (v3 task #11) + +**Date:** 2026-07-15 +**Status:** design complete (resolves v3 open item "Calculation driver mini-design"). +**Parent:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` §"Calculated tags (two-tier)". + +Signal-level calculated tags are ordinary **raw tags** bound to a `Calculation` driver. They +inherit the entire raw pipeline (folders, tag-groups, CSV, historization, native alarms, UNS +references) with no special-casing. This doc pins the runtime mechanism, storage, triggers, +gates, and error semantics. + +## 1. Driver shape + +`CalculationDriver : IDriver, ISubscribable, IReadable` in a new +`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` project. + +- **Not `IWritable`** — calc tags are computed sources; author them read-only + (`AccessLevel` enforced at authoring + CSV validation). +- **Not `ITagDiscovery`** — tags are authored, never discovered + (`SupportsOnlineDiscovery` default false ⇒ no Browse button, manual/CSV entry only — + consistent with the universal-browser gate). +- `IReadable.ReadAsync` returns the last computed snapshot per ref + (`BadWaitingForInitialData` before first evaluation). +- Registration: `CalculationDriverFactoryExtensions.DriverTypeName = "Calculation"` + one + line in `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` and one `TryAddEnumerable` in + `AddOtOpcUaDriverProbes` — the standard pattern. *(Note: the live registration seam is + `DriverFactoryRegistry`; `DriverTypeRegistry` (metadata/JSON-schema) is vestigial — nothing + in `src/` calls its `Register`.)* +- Devices: one auto-created default device (`Engine`), empty `DeviceConfig`. No PollGroups. +- `GetHealth()` = always Connected (no backend); memory footprint = compiled-cache size. + +## 2. Per-tag `TagConfig` + +```json +{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 } +``` + +- `scriptId` (required) — logical FK to the **shared `Script` entity** (same convention as + `VirtualTag.ScriptId`; generation-scoped, `SourceHash` compile-cache key). The AdminUI tag + editor reuses the VirtualTagModal's script dropdown + "New script" + inline Monaco panel + components; the standalone `/scripts/{id}` page works unchanged. +- Trigger: `changeTriggered` (default **true**) and/or `timerIntervalMs` (≥ 50), at least one + required — the authored shape mirrors `VirtualTag`, but see §4 for the runtime reality. + +## 3. Value production — how inputs reach the driver + +**The gap:** drivers are host-blind; nothing feeds one driver another driver's values. The +per-node `DependencyMuxActor` already receives **every** `AttributeValuePublished` (via +`DriverHostActor.ForwardToMux`), keyed by wire-ref — under v3, RawPath. + +**Mechanism — a new composable capability interface** (in the spirit of `IDriver`'s +capability set): + +```csharp +/// Optional capability: the driver consumes other tags' live values. +/// The host registers the declared refs with its dependency mux and forwards changes. +public interface IDependencyConsumer +{ + /// Wire-refs (RawPaths) this driver needs, derived from its authored tags. + /// Re-read by the host after Initialize/Reinitialize. + IReadOnlyCollection DependencyRefs { get; } + + /// Host push of a dependency value change. Called from an actor context — + /// implementations must be non-blocking. + void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc); +} +``` + +`DriverHostActor` wiring (~30 lines): when a spawned driver implements `IDependencyConsumer`, +spawn a tiny mux-adapter child that `RegisterInterest(driver.DependencyRefs, Self)` on the +existing `DependencyMuxActor` and forwards `DependencyValueChanged` into the driver callback. +Re-register on every apply (refs change when tags/scripts change). + +**Output path is the ordinary driver path** — evaluation raises `ISubscribable.OnDataChange` +→ `DriverInstanceActor` → `AttributeValuePublished` → node fan-out **and** back into the mux. +Two things fall out for free: + +1. **Calc-of-calc chains work with no extra machinery** — calc A's published output re-enters + the mux, calc B (subscribed to A's RawPath) re-evaluates. This is exactly why the + deploy-time **cycle gate** (§5) is mandatory: an undetected A→B→A cycle is a live + oscillation loop, not just a stale value. +2. Historization, native alarms, and UNS references attach to the calc tag's raw node exactly + like any driver tag. + +`DependencyRefs` = the union of `EquipmentScriptPaths.ExtractDependencyRefs(source)` over the +driver's authored tags' scripts (literal-only `ctx.GetTag` paths, enforced by +`DependencyExtractor`). + +## 4. Evaluation engine + triggers + +- **Evaluator:** a `CalculationEvaluator` with the same construction as + `RoslynVirtualTagEvaluator` — `CompiledScriptCache` keyed by + source, `TimedScriptEvaluator` (2 s default timeout), passthrough fast-path, single-tag + mode (`ctx.SetVirtualTag` dropped + logged). Reuses `VirtualTagContext` so the Monaco + editor, sandbox, and diagnostics pipeline apply verbatim. Catalog keys for completions are + RawPaths; the `{{equip}}` branch is already conditional in `ScriptAnalysisService` and + simply never triggers for raw-level scripts. +- **Change trigger:** evaluate on `OnDependencyValue` for any declared dep of that tag, + gated like `VirtualTagActor`: publish nothing until all declared deps have arrived at least + once; dedupe equal results. +- **Timer trigger:** owned **inside the driver** by reusing the dormant-but-tested + `TimerTriggerScheduler` (`Core.VirtualTags`) — one timer per distinct interval group, + per-group in-flight skip. *(Fact: the live VirtualTag runtime is change-trigger only — + `VirtualTagActor` has no timer handler and `EquipmentVirtualTagPlan` drops the trigger + fields. The Calc driver revives the scheduler without touching the VT runtime; whether the + VT plan seam later gains timer support is out of scope here.)* +- **Concurrency:** evaluations run inline on the driver's dispatch (same "fast enough to run + inline" contract as the VT evaluator); the compile cache makes steady-state evaluation + cheap. Compiled scripts are dropped on `ReinitializeAsync` when the source set changed + (`SourceHash` comparison, mirroring `IScriptCacheOwner` usage). + +## 5. Deploy gates (new — `DraftValidator`) + +Today `DraftValidator` checks only name uniqueness; `DependencyGraph` (Tarjan SCC) exists but +is dormant. v3 adds, for `Calculation`-bound tags: + +1. **`scriptId` existence** — the referenced `Script` row must exist in the draft generation. +2. **Cycle gate (hard error)** — build the calc→calc edge set (tag → deps that are themselves + Calculation tags) and run `DependencyGraph.DetectCycles`; any SCC ⇒ deploy error naming + the cycle members. Cross-driver refs (calc reading a Modbus tag) are terminal nodes, + never edges. +3. **Compile: deliberately NOT a hard gate** — parity with VirtualTags. The editor's live + diagnostics mirror the compile exactly, the `StartDeployment` compile-cost advisory stays + warn-only, and a runtime compile failure lands as Bad quality + script-log (§6). A hard + compile gate would make deploys O(scripts) slow and diverge Calc tags from VT behavior. + +## 6. Error semantics (mirrors `VirtualTagActor` 02/S13) + +- Evaluator throw / compile error / timeout ⇒ publish **Bad** quality carrying the last-known + value, once per Good→Bad transition, only after all deps have arrived; emit a + `ScriptLogEntry` on the `script-logs` DPS topic per failure (visible on the script-log + panel). Recovery to Good is force-published. No retry — re-evaluation on next trigger. +- Historized Bad results record `BadInternalError` (0x80020000), matching VT historization. + +## 7. Probe + +`CalculationDriverProbe : IDriverProbe` (`DriverType = "Calculation"`). `ProbeAsync` = +validate the driver config parses; there is no backend to connect. (Per-script compile +verification belongs to the editor diagnostics + the tag editor's inline panel, not the +driver probe — the probe receives only `DriverConfig`, which for Calculation is empty.) +Returns Ok with negligible latency; never throws. + +## 8. Testing + +- **Unit:** evaluator parity tests vs the VT evaluator (passthrough, timeout, sandbox + violation, SetVirtualTag drop); dependency-ref extraction from authored tags; change-gate + (no publish until all deps seen); dedupe; timer-group scheduling via + `TimerTriggerScheduler`; Bad-transition + script-log emission; cycle gate (self-cycle, + 2-cycle, cross-driver terminal). +- **Integration:** deploy a Calc tag reading a fixture-driver tag on the 2-node harness; + assert the raw node publishes computed values, a UNS reference sees them, and calc-of-calc + chains propagate. +- **Live `/run` (Batch 2 gate):** author a Calc driver + tag on docker-dev via `/raw`, + script it against a Modbus fixture tag, watch the value compute; break the script, watch + Bad + script-log row; fix, watch recovery. + +## 9. Out of scope + +- Multi-tag scripts / `ctx.SetVirtualTag` fan-out (single-tag mode, as VT today). +- Timer support in the UNS VirtualTag runtime (dormant there; revived only inside this driver). +- Write-through calc tags (inverse calculations). diff --git a/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md b/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md index 3877094d..b2523473 100644 --- a/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md +++ b/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md @@ -1,7 +1,13 @@ # v3 — Kepware-style Raw tree + UNS projection (two-subtree address space) **Date:** 2026-07-15 -**Status:** Design approved (brainstorming complete) — ready for implementation planning +**Status:** Design approved (brainstorming complete) — **revised 2026-07-15 after a +codebase-grounded design review** (identity contract added, delegate mechanism reworded, +`{{equip}}` redesigned, Device-table materialization scoped, Batch 1 re-scoped, ScadaBridge +claim corrected, `WriteIdempotent` restored), then reconciled with the pre-v3 universal +discovery browser (`2026-07-15-universal-discovery-browser-design.md`). **All open items +resolved 2026-07-15** (live historian probe, SDK source spike, code audits; see +[Open items](#open-items)). Ready for implementation planning. **Version:** Next major version (v3). The project has no git tags; "v2" is the conceptual current major (the Akka-fused rebuild, `docs/v2/`), so this is v3. @@ -21,7 +27,10 @@ config data is preserved; dev rigs are re-seeded against a fresh schema. - **Raw is the source of truth; UNS references it.** A tag is authored once, under a driver in the Raw tree. UNS holds references that re-point existing raw tags into equipment. One - tag, two views — rename/retype in Raw propagates automatically. + tag, two views — rename/retype in Raw propagates automatically *within the server*. + (Rename is **not** free downstream: it changes the RawPath and therefore the NodeId, + the default historian tagname, script literals, and external raw bindings — see + [Error handling / edge cases](#error-handling--edge-cases).) - **Raw hierarchy:** `Cluster → Folder(s, nestable) → Driver → Device → TagGroup(s, nestable) → Tag`. Full Kepware parity. - **Raw is global, cluster-rooted**, mirroring the existing global `/uns` page (which is @@ -30,13 +39,26 @@ config data is preserved; dev rigs are re-seeded against a fresh schema. - **UNS authoring is reference-only (clean break).** The Equipment editor no longer binds a driver or defines `TagConfig`; it references existing raw tags (with an optional UNS display-name override) and continues to host VirtualTags + scripted alarms. -- **OPC UA exposure:** two namespaces — `ns=Raw` (stable device-path NodeIds, **always on**) - and `ns=UNS` (equipment-path NodeIds). UNS nodes `Organizes →` their backing raw nodes. -- **UNS ↔ Raw linkage = delegate/redirect (not value-copy).** A UNS node is a thin variable - whose reads/writes/subscriptions are delegated to the backing raw node's single value - source. No double-buffering, no drift. +- **OPC UA exposure:** two namespaces — `ns=Raw` (device-path NodeIds, stable **absent + rename**, always on) and `ns=UNS` (equipment-path NodeIds). UNS nodes `Organizes →` their + backing raw nodes. +- **UNS ↔ Raw linkage = single value source, fanned out (not delegation, not independent + buffering).** The driver publish path remains the *only* writer of tag values. A UNS node + is a second NodeId registered against the same driver ref in the existing 1:N value + fan-out map (`DriverHostActor._nodeIdByDriverRef`) and the write inverse map + (`_driverRefByNodeId`). Both node states hold the value the single publish path wrote — + no independent buffer exists, so no drift is possible. *(Reworded from the original + "delegate/redirect": the OPC Foundation stack as used here has no OnRead delegation path — + monitored items sample the stored `BaseDataVariableState.Value` — and the fan-out map is + already explicitly 1:N. The intent, one source of truth, is delivered by the existing + machinery.)* - **Tag creation is capability-based:** manual entry + CSV import/export for **all** drivers; - live **Browse** only for drivers that advertise a browser (OpcUaClient, Galaxy today). + live **Browse** gated by the **two-tier browser resolution** landing before v3 + (`docs/plans/2026-07-15-universal-discovery-browser-design.md`): bespoke `IDriverBrowser` + first (OpcUaClient, Galaxy), else the universal `DiscoveryDriverBrowser` when the driver's + `ITagDiscovery.SupportsOnlineDiscovery` is true (AbCip, TwinCAT, FOCAS at v3 time; future + discovery drivers for free), else manual entry. Flat-address drivers (Modbus, S7, AbLegacy) + stay manual-entry by nature. - **Calculated tags — two-tier:** - *Signal-level* calcs = raw tags bound to a **`Calculation` pseudo-driver** whose value source is the existing Roslyn scripting engine. They reuse the entire raw pipeline @@ -53,19 +75,81 @@ config data is preserved; dev rigs are re-seeded against a fresh schema. - A **`ServerCluster`** (PK `ClusterId`) owns a **flat list** of `DriverInstance` rows — there is **no folder/grouping** construct for drivers today. -- Drivers have `Device` rows (multi-device drivers only) and `PollGroup` rows. +- Drivers have `Device` rows (multi-device drivers only) and `PollGroup` rows. **Caveat + (review finding):** the `Device` EF table is **vestigial** — no production code path writes + it; the "multi-device" drivers (AbCip, AbLegacy, TwinCAT, FOCAS) embed a `Devices[]` array + inside `DriverConfig` JSON, and single-endpoint drivers embed `Host`/`EndpointUrl` in + `DriverConfig`. v3 materializes the table for real — see the Device entry below. - **`Tag`** binds to a driver via `DriverInstanceId` + schemaless `TagConfig` JSON and lives either under an `Equipment` (`EquipmentId`) or at a namespace-root `FolderPath`. +- **Tag identity today is `FullName`, which is *derived*, not stored**: `TagConfigIntent.Parse` + re-derives it from `TagConfig` at every seam. Galaxy/OpcUaClient write an explicit + `"FullName"` key (dotted/opaque ref); **the six protocol drivers do not — their FullName is + the entire raw `TagConfig` JSON blob.** That one string is simultaneously the driver + wire-ref (`EquipmentTagRefResolver` key), the value fan-out key, the write-routing target, + the historian default tagname + mux interest key, the native-alarm `ConditionId` (both + directions), and the `ctx.GetTag("…")` script path. v3 replaces it — see + [v3 identity contract](#v3-identity-contract-rawpath). - The **UNS tree** (Enterprise → Cluster → Area → Line → Equipment → Tag/VirtualTag) is the - **only** authored tree and is what the address space exposes. + **only** authored tree and is what the address space exposes. NodeIds are + **logical-id-based** (`{EquipmentId}/{FolderPath}/{Name}` via `EquipmentNodeIds`), in a + **single custom OPC UA namespace** (`https://zb.com/otopcua/ns`). - Driver authoring is a page-per-type flow: `/clusters/{id}/drivers` → `DriverTypePicker` → 8 typed Razor pages (`ModbusDriverPage`, …) dispatched by `DriverEditRouter._componentMap`. -- `DriverType` dispatch is duplicated in ~3 hard-coded places with pre-existing string - mismatches: `TwinCAT`/`TwinCat`, `FOCAS`/`Focas`, `GalaxyMxGateway`/`galaxy`. +- `DriverType` dispatch is duplicated in ≥4 hard-coded places with pre-existing string + mismatches: `TwinCAT`/`TwinCat`, `FOCAS`/`Focas`, `GalaxyMxGateway`/`galaxy` + (`DriverEditRouter`, `TagConfigEditorMap`, `TagConfigValidator`, + `EquipmentTagConfigInspector`). - Runtime: Akka actors — `DriverHostActor` (per node/role) → `DriverInstanceActor` (per driver) — bind driver values through `EquipmentTagRefResolver`/`TagConfig.FullName`. - Address-space pipeline: `AddressSpace*` (Composer → Composition → Planner → Plan → Applier). +## v3 identity contract (RawPath) + +The single most load-bearing change in v3. The blob-as-FullName convention is replaced by one +canonical identity string used at **every** seam: + +**`RawPath`** — the cluster-scoped raw device path, slash-separated: +`////`. It is simultaneously: + +| Seam | Today's key | v3 key | +|---|---|---| +| `ns=Raw` NodeId | — (no raw tree) | `s=` | +| Driver wire-ref (`EquipmentTagRefResolver`) | FullName (= TagConfig blob for 6/8 drivers) | RawPath | +| Value fan-out (`_nodeIdByDriverRef`) | `(DriverInstanceId, FullName)` | `(DriverInstanceId, RawPath)` | +| Write routing (`WriteAttribute`) | FullName | RawPath | +| Historian default tagname + `MuxRef` | FullName | RawPath (override via `historianTagname` unchanged) | +| Native-alarm `ConditionId` | FullName | RawPath | +| Script `ctx.GetTag` path / mux dependency ref | FullName | RawPath | + +**Driver-side resolution changes in all 8 drivers.** The `EquipmentTagRefResolver`'s +`_byName` branch becomes the primary path: each deploy hands the driver its authored raw tags +keyed by RawPath, and the driver looks the ref up to obtain the `TagConfig`-derived +definition. The per-driver `EquipmentTagParser` blob-fallback (e.g. S7's leading-`{` +convention) is retired; ad-hoc/unparsed refs are no longer a supported input. This is +per-driver work and is scoped into Batch 1 (see Build plan). + +**`TagConfig` no longer carries identity.** For Galaxy/OpcUaClient the address +(`tag_name.AttributeName` / upstream node-id) stays inside `TagConfig` as an ordinary +driver-specific address field — it is what the driver *dials*, not what the system *keys on*. +`TagConfigIntent` sheds its FullName-derivation role. + +**TagConfig key normalization (greenfield, decided):** with identity gone from `TagConfig`, +two legacy conventions are cleaned up: (a) the PascalCase `"FullName"` key (OpcUaClient, +Galaxy — the case-sensitive `TagConfigIntent` contract) becomes an ordinary camelCase +address key — `nodeId` for OpcUaClient, `attributeRef` for Galaxy; (b) the +`deviceHostAddress` key (AbCip, AbLegacy, TwinCAT, FOCAS) is **dropped entirely** — the +tag's `DeviceId` FK now owns device placement, so the key is structurally redundant. + +**Historian tagname limit — live-verified 2026-07-15 against the real AVEVA historian** +(wonder-sql-vd03 via the local HistorianGateway, `grpcurl` probe): tagnames are accepted up +to **255 characters** (256 is rejected — `EnsureTag returned false`); `/`, space, `-`, `_`, +and `.` are all accepted; path-shaped names round-trip **byte-exactly** through +`EnsureTags → WriteLiveValues → ReadRaw` with GOOD (192) quality, and wildcard browse works. +**Policy:** a historized tag whose *effective* historian tagname (override else RawPath) +exceeds 255 characters is a **`DraftValidator` deploy error** telling the author to set a +shorter `historianTagname` override — never a silent truncation. + ## Data model (greenfield schema) ### Raw side @@ -73,14 +157,29 @@ config data is preserved; dev rigs are re-seeded against a fresh schema. FK, nullable `ParentRawFolderId` (null = root under cluster), `Name`, `SortOrder`. - **`DriverInstance`** *(reshaped)* — gains nullable `RawFolderId` (null = cluster root). Keeps `ClusterId`, `DriverType`, `DriverConfig`, `ResilienceConfig`. **Drops** per-driver - `NamespaceId` (namespaces become implicit — see Address space). -- **`Device`** *(kept, now universal)* — `DeviceId` PK, `DriverInstanceId` FK, `Name`, - `DeviceConfig` JSON. **Every driver has ≥1 device**; single-endpoint drivers (S7, TwinCAT, - FOCAS, OpcUaClient, Galaxy, Calculation) get an auto-created *default* device. + `NamespaceId`. The **`Namespace` entity, `NamespaceKind`, and `NamespaceKindCompatibility` + are retired outright** — the two OPC UA namespaces are implicit (see Address space). + `NamespaceKind.Simulated` **verified vestigial 2026-07-15**: it is a reserved enum member, + a "reserved" dropdown option in `NamespaceEdit.razor`, and a `NamespaceKindCompatibility` + flag documented "future Simulated namespace (replay driver — not in v2.0)" — no driver + declares it, no seed populates it, and the composer filters on `Kind == Equipment` only. + Retired with the entity; a future replay/simulator driver is simply another Raw + `DriverType`, which the v3 model handles natively. +- **`Device`** *(materialized for real, now universal)* — `DeviceId` PK, `DriverInstanceId` + FK, `Name`, `DeviceConfig` JSON. **Every driver has ≥1 device**; single-endpoint drivers + (S7, TwinCAT, FOCAS, OpcUaClient, Galaxy, Calculation) get an auto-created *default* + device. **Endpoint/connection settings move from `DriverConfig` into `DeviceConfig`** for + all drivers (the Kepware channel/device split: `DriverConfig` keeps protocol/channel-level + settings; `DeviceConfig` carries host/endpoint + per-device settings). This retires the + embedded `Devices[]` arrays in the four multi-device drivers' `DriverConfig` and reworks + their options schemas, Razor pages, `DeviceConfigIntent`, the artifact device-host map, and + runtime driver spawning. *(Review finding: this is real migration work, not a "kept" table + — it is explicitly scoped into Batches 1–2.)* - **`TagGroup`** *(new)* — nestable tag folder under a device. `TagGroupId` PK, `DeviceId` FK, nullable `ParentTagGroupId`, `Name`, `SortOrder`. - **`Tag`** *(reshaped → raw-only)* — `TagId` PK, `DeviceId` FK (required), nullable - `TagGroupId`, `Name`, `DataType`, `AccessLevel`, nullable `PollGroupId`, `TagConfig` JSON. + `TagGroupId`, `Name`, `DataType`, `AccessLevel`, **`WriteIdempotent`** *(retained — dropping + it would regress the R2 resilience fix)*, nullable `PollGroupId`, `TagConfig` JSON. **Drops `EquipmentId` and `FolderPath`.** - **`PollGroup`** *(unchanged)* — driver-scoped polling groups. @@ -90,6 +189,22 @@ config data is preserved; dev rigs are re-seeded against a fresh schema. - **`UnsTagReference`** *(new)* — the projection. `EquipmentId` FK, `TagId` FK (→ raw tag), nullable `DisplayNameOverride`, `SortOrder`. - **`VirtualTag`, `ScriptedAlarm`** *(unchanged)* — per-equipment, UNS-native. +- The orphaned `EquipmentImportBatch`/`EquipmentImportRow` tables (never wired to any service + or UI) are **dropped** in the greenfield schema. + +### Uniqueness + naming rules *(new — required by path-shaped NodeIds)* +- Sibling-name uniqueness at every raw level: `RawFolder (ClusterId, ParentRawFolderId, Name)`, + `DriverInstance (ClusterId, RawFolderId, Name)`, `Device (DriverInstanceId, Name)`, + `TagGroup (DeviceId, ParentTagGroupId, Name)`, `Tag (DeviceId, TagGroupId, Name)` — all + unique (filtered indexes for the nullable parents), mirroring today's careful unique-index + conventions. +- Raw names forbid `/` (and leading/trailing whitespace) — validated at authoring; `/` is the + RawPath separator and would corrupt NodeIds. +- **UNS effective-leaf uniqueness:** within an equipment, the *effective* name + (`DisplayNameOverride` else the raw tag's `Name`) must be unique **across references, + VirtualTags, and ScriptedAlarms**. Enforced at authoring (service-level check) **and** at + deploy time in `DraftValidator` — the deploy gate is what catches rename-induced collisions + (a raw rename can create a clash the authoring check never saw). ### Integrity rules - Deleting a raw `Tag` is **blocked while any `UnsTagReference` points at it**; the UI names @@ -110,10 +225,21 @@ New global page **`/raw`** (peer to `/uns`), a lazy, cluster-rooted project tree - **TagGroup** — New group / Add tags ▸ / Rename / Delete. - **Tag** — Edit / Delete. +**New AdminUI infrastructure (review finding — budget for it in Batch 2):** +- **Right-click context menus do not exist anywhere in the Blazor app** (the only + `ContextMenu` is in the Avalonia desktop client). A reusable `oncontextmenu` component is + new build, including keyboard/touch fallback (an explicit "⋯" affordance per node so the + menu is reachable without right-click). +- **Lazy tree loading is plumbed but never exercised**: `UnsNode.HasLazyChildren`/`Loading` + exist, but `GlobalUns` eager-loads the whole structure. `/raw` wires lazy loading for the + first time (per-device tag counts can be large). + **Reuse (not rebuild):** - *Configure driver* hosts the existing typed driver forms (`ModbusDriverPage` et al.) - refactored into embeddable form bodies inside a `DriverConfigModal`. Only the page - shell/routing changes. + refactored into embeddable form bodies inside a `DriverConfigModal`. The shared inner + sections already exist (`DriverFormShell`, `DriverIdentitySection`, `DriverTestConnectButton`, + `DriverResilienceSection`); what moves out of each ~500-line page is the page-owned + `@page` route, `EditForm`, ClusterNav header, DB load, and post-save navigation. - *Test connect* uses the existing `DriverTestConnectButton` + `IDriverProbe` path (transient config, no persistence). - *Edit tag* uses the existing per-driver `TagConfig` editors via `TagConfigEditorMap`. @@ -121,13 +247,41 @@ New global page **`/raw`** (peer to `/uns`), a lazy, cluster-rooted project tree **Add tags ▸ (capability-based)** on a Device or TagGroup: - **Manual entry** — grid: name, datatype, access + the driver-typed `TagConfig` editor. - **Import/Export CSV** — columns `Name, TagGroupPath, DataType, AccessLevel, - `; staged-then-commit like today's - `EquipmentImportBatch`. Export round-trips the same shape. -- **Browse device…** — enabled only when the driver's factory advertises a browser - (`DriverBrowseTree` + `IBrowserSessionService`); grayed out with a tooltip otherwise. + , TagConfigJson`. Per-driver column dictionaries are **decided** + (see the appendix "CSV column dictionaries"); the nested `alarm` object rides flat + dotted sub-columns (`alarm.alarmType`, `alarm.severity`, `alarm.historizeToAveva`); + anything else non-flat rides the `TagConfigJson` fallback column. Staged + parse → review grid → commit (RFC 4180 parser). Export round-trips the same shape. + *(Review correction: the previously cited `EquipmentImportBatch` staged flow is **orphaned + dead code** — never referenced by any service or UI — and today's live equipment import is + a naive non-staged, comma-split, metadata-only path with **no tag import at all**. The tag + CSV importer is a from-scratch feature, including an RFC 4180 parser; the orphaned tables + are dropped, not extended.)* +- **Browse device…** — enabled per the two-tier resolution in `BrowserSessionService` + (bespoke browser → universal `DiscoveryDriverBrowser` when `SupportsOnlineDiscovery` → + manual entry; see `docs/plans/2026-07-15-universal-discovery-browser-design.md`, which + lands **before v3** against the current picker); grayed out with a tooltip when neither + tier applies. **v3 re-targets the browse commit** (the session/tree layer — + `IBrowseSession`, `BrowseSessionRegistry`, `DriverBrowseTree` — is reused as-is): + - *Commit contract:* multi-selected leaves become **raw `Tag` rows** under the target + Device/TagGroup — `Name` from the leaf's browse name, `DataType` from + `DriverAttributeInfo`, and the leaf's driver reference (`attr.FullName`) written into + the **driver-typed `TagConfig` as an address field**. Under the v3 identity contract + that value is what the driver *dials*, not the system identity — the tag's identity is + its RawPath. (The universal-browser doc's "commit `TagConfig.FullName`" wording + describes the pre-v3 picker; see its v3 forward note.) + - *Folder mirroring:* the captured browse folder nesting is offered as an opt-in + "create matching tag-groups" toggle — browse folders map naturally onto nested + `TagGroup`s. + - *Config input:* v3 moves endpoints into `DeviceConfig`, so the browse modal passes the + **merged Driver + Device config** as the browser's `configJson` (the universal browser + connects a real ephemeral driver instance; it needs the device endpoint). -**Cleanup folded in:** canonicalize the `DriverType` strings to a single source of truth -(the tree dispatches on driver type in one place instead of three). +**Cleanup folded in:** canonicalize the `DriverType` strings to a single source of truth — +one constants class consumed by `DriverEditRouter`, `TagConfigEditorMap`, +`TagConfigValidator`, and `EquipmentTagConfigInspector` (the four drifted dispatch maps), +with the driver factories' `DriverTypeName` as the authority (`TwinCAT`, `FOCAS`, +`GalaxyMxGateway`). ## UNS Equipment — reference-only @@ -138,67 +292,161 @@ The `/uns` tree is unchanged; the **Equipment Tags tab** becomes a reference lis to pull many raw tags at once. - Each row is a `UnsTagReference`: shows the raw path, inherited datatype/access (read-only), and an optional display-name override. -- **VirtualTags and scripted alarms** stay exactly as they are. +- **VirtualTags and scripted alarms** stay as they are, **except the `{{equip}}` seam, which + is redesigned** (see Scripting below) — the current derivation (shared first-dot prefix of + child-tag FullNames) cannot survive reference-only equipment. - The old `ImportEquipmentModal` drops its `DriverInstanceId` column (equipment no longer carries a driver). - *Deferred to a follow-up:* pattern/CSV-based auto-linking of raw → UNS. +## Scripting — paths and `{{equip}}` under v3 + +- **`ctx.GetTag("…")` takes a RawPath.** The mux dependency refs, the read-cache keys, and + the Monaco tag catalog all move to RawPath (the catalog lists raw tags by RawPath; + VirtualTags stay by bare name). +- **`{{equip}}` is redefined as reference-relative resolution** (replaces prefix + derivation): `ctx.GetTag("{{equip}}/")` resolves **through the equipment's + `UnsTagReference` rows by effective name** to the backing RawPath, at the same two compose + seams that substitute today (`AddressSpaceComposer` + `DeploymentArtifact`). An unresolved + `` is a **deploy-time validation error** (better than today's silent + null-base no-substitution). This decouples equipment scripts from raw topology — exactly + what per-equipment instantiation wants — and gives Monaco a well-defined + equipment-relative completion list (the reference effective names). +- Alarm `{TagPath}` message tokens follow the same rule. + ## OPC UA address space + runtime binding Runs through the existing `AddressSpace*` pipeline. **Two namespaces per server** (each running cluster exposes only its own): -- **`ns=Raw`** — NodeIds are stable device paths: - `s=////`. Folders/drivers/devices/tag-groups are - `Object`/`Folder` nodes; tags are `Variable` nodes. -- **`ns=UNS`** — NodeIds are equipment paths: `s=///`. +- **`ns=Raw`** — NodeIds are `s=`. Folders/drivers/devices/tag-groups are + `Object`/`Folder` nodes; tags are `Variable` nodes. Stable absent rename. +- **`ns=UNS`** — NodeIds are equipment paths: `s=///`. Area/Line/Equipment are folders; each `UnsTagReference` is a UNS variable node. -**UNS ↔ Raw = delegate/redirect:** the UNS node is a thin variable whose reads/writes/ -subscriptions delegate to the backing raw node's single value source; an `Organizes` +**UNS ↔ Raw = single source, fan-out** (see Decisions): the raw and UNS variable nodes are +two NodeIds registered against the same `(DriverInstanceId, RawPath)` in the existing 1:N +forward map; the write inverse map gains the UNS NodeId → same ref entry. An `Organizes` reference UNS → Raw makes the linkage browsable. **Runtime binding flows through Raw only.** Driver-host actors bind driver values to **raw** -tag nodes (via `EquipmentTagRefResolver`/`FullName`, now resolving a raw device path). UNS -nodes never bind a driver directly. Writes to a UNS node route to the raw node's driver (same -`WriteOperate` gating). HistoryRead, native alarms, and continuous historization all attach -at the **raw** node and are visible through the UNS reference. +tag nodes via the RawPath contract. UNS nodes never bind a driver directly. Writes to a UNS +node route to the raw node's driver (same `WriteOperate` gating). -**Cross-repo impact:** ScadaBridge's Data Connection Layer keeps its UNS/equipment bindings -(same equipment paths) and *gains* the option to bind raw device paths directly. Record this -v3 contract change in the scadaproj umbrella index (`../scadaproj/CLAUDE.md`). +**Historian via UNS (explicit registration, not automatic):** the raw node carries +`Historizing=true` and the `_historizedTagnames` entry; the UNS reference node is registered +with the **same** historian tagname and the HistoryRead access bit, so HistoryRead works +against either NodeId. The mux `HistorizedTagRef` stays single (keyed by RawPath) — no double +historization. + +**Alarms/events across the two namespaces:** a native alarm condition is a **single +condition instance** materialized at the raw tag (parent = its device/group folder, which is +promoted to an event notifier, as equipment folders are today). Each *referencing* +equipment's UNS folder is **also** registered as an event notifier surfacing that same +condition, so a client subscribed at the equipment sees native alarm events without touching +`ns=Raw`. `/alerts` row identity: **one row per condition**, primary identity = RawPath + +condition NodeId; `AlarmTransitionEvent` gains the (possibly empty) list of referencing +equipment paths for display. Scripted alarms stay per-equipment in `ns=UNS`, unchanged. + +*SDK spike resolved (2026-07-15, verified against the vendored UA-.NETStandard source):* +**native multi-notifier wiring is supported — use it; do NOT duplicate `ReportEvent`.** +`NodeState.ReportEvent` bubbles through the single HasComponent parent chain **plus every +inverse entry in the node's notifier list**, so one condition fans one event to any number +of notifier roots. Per additional (non-parent) root folder: + +```csharp +alarm.AddNotifier(SystemContext, null, isInverse: true, extraFolder); // upward bubble +extraFolder.AddNotifier(SystemContext, null, isInverse: false, alarm); // downward AreEventsMonitored +EnsureFolderIsEventNotifier(extraFolder); // SubscribeToEvents + AddRootNotifier +``` + +(the exact pattern the SDK's own `TestDataNodeManager` quickstart exercises). A single +`alarm.ReportEvent(...)` then reaches every wired root; Server-object subscribers get +**one** copy because the shared `InstanceStateSnapshot` reference is deduped by the event +queue. The duplicate-report fallback is rejected: per-root re-reporting mints distinct +`EventId`s/snapshots, defeating the dedup (double delivery to Server-object subscribers) and +breaking Part 9 ack correlation. Two implementation obligations: **teardown symmetry** +(`RemoveNotifier(..., bidirectional: true)` alongside the existing `RemoveRootNotifier` +paths on rebuild/subtree-removal, tracked like `_notifierFolders`, or inverse-notifier +entries leak across redeploys) and a Batch 4 live check that a Server-object subscriber +receives exactly one copy per transition while folder-scoped subscribers in each namespace +receive the condition's events. + +**Sink interface changes carry the forwarding trap:** `IOpcUaAddressSpaceSink` / +`ISurgicalAddressSpaceSink` methods gain a Raw/UNS discriminator (or NodeIds become +namespace-qualified). Every new or changed method **must** forward through +`DeferredAddressSpaceSink`, and `DeferredSinkForwardingReflectionTests` must be extended to +cover the new surface — this is part of Batch 4's definition of done, not an afterthought. + +**Cross-repo impact (corrected + sized 2026-07-15):** ScadaBridge's Data Connection Layer +does **not** keep its bindings — today's NodeIds are logical-id-based +(`{EquipmentId}/{FolderPath}/{Name}`), v3's are name-path-based in new namespaces. +**Every ScadaBridge binding re-binds.** The re-bind is a *data* migration, not a code +rewrite: ScadaBridge treats the reference as an opaque `NodeId.Parse`-able string held in +exactly **two DB columns** (`TemplateAttribute.DataSourceReference`, +`InstanceConnectionBinding.DataSourceReferenceOverride`) plus native-alarm source refs, +heartbeat `TagPath`s, and any exported Transport bundles — nothing parses the shape. +The fragile part: stored references hard-code the **namespace index** (`ns=2;…`) and +ScadaBridge stores no namespace URI, so v3's new namespaces invalidate every stored index. +**Cutover plan:** (1) v3.0 lands and rigs re-seed; (2) ScadaBridge re-authors bindings via +its existing OPC UA picker (greenfield means no reliable automatic old→new mapping); +(3) recommend ScadaBridge move to `nsu=`-qualified references while re-binding, so future +namespace-table changes can't strand it again (ScadaBridge-side follow-up); (4) update the +scadaproj umbrella index when the contract lands. ## Calculated tags (two-tier) - **`Calculation` pseudo-driver** *(new DriverType)* — signal-level calc tags are raw tags bound to this driver. Registered in `DriverTypeRegistry` like any driver; a single default - `Engine` device; the tag's `TagConfig` holds a **script reference** instead of a register - address, and its value source is the existing Roslyn scripting engine (`ctx.GetTag`, - publish gate, Monaco editor). Wire it into the driver-host value fan-out. It inherits - folders, CSV, historization, native alarms, and UNS references for free. + `Engine` device; the tag's `TagConfig` holds a **script reference** (`ScriptId`) plus the + **trigger model mirrored from VirtualTags**: `changeTriggered` (re-evaluate on any input + change) and/or `timerIntervalMs` (≥ 50), at least one required. Inputs are `ctx.GetTag` + RawPath literals resolved through the same dependency mux; the value source is the existing + Roslyn engine (publish gate, Monaco editor, script-log all reused). **Cycle rule:** the + calc-tag dependency graph (calc → calc edges) is checked at deploy; a cycle is a + `DraftValidator` error. No PollGroups; the driver's `IDriverProbe` is a script-compile + check. It inherits folders, CSV, historization, native alarms, and UNS references for free. - **UNS VirtualTags** *(unchanged)* — retained for equipment-context computations that need `{{equip}}` templating / per-equipment instantiation. - **Author rule:** signal-level → Raw (`Calculation` driver); equipment-context or cross-equipment → UNS VirtualTag. +- **Mini-design complete:** `docs/plans/2026-07-15-calculation-driver-mini-design.md` + (2026-07-15) — driver shape (`IDriver + ISubscribable + IReadable`, not writable/browsable), + the new `IDependencyConsumer` capability interface feeding the driver from the existing + per-node dependency mux, shared `Script` entity via `TagConfig.scriptId`, change-trigger + via mux + timer-trigger by reviving the dormant `TimerTriggerScheduler` inside the driver, + deploy gates (scriptId existence + Tarjan cycle check via the dormant `DependencyGraph`; + compile deliberately NOT hard-gated, parity with VirtualTags), and VT-parity error + semantics (Bad + last-known value + script-log). Calc-of-calc chains work for free because + published calc values re-enter the mux — which is exactly why the cycle gate is mandatory + (an undetected cycle is a live oscillation loop). ## Build plan (Approach B — phased) +*(Re-sliced after review: the original Batch 1 was unbuildable — dropping `Tag.EquipmentId`/ +`FolderPath` and `Equipment.DriverInstanceId` breaks the composer, artifact, runtime binding, +`DraftValidator`, `UnsTreeService`, and `EquipmentPage` at compile time. Batch 1 now owns +that rewiring explicitly; the address space is intentionally dark until Batch 4.)* + | Batch | Content | Verification | |---|---|---| -| **1 — Schema** | Greenfield EF model + migration + dev-rig re-seed | build/test; migration applies; seed loads | -| **2 — Raw UI** | `/raw` project tree, right-click popups, driver config/test, tag manual/CSV/browse, `Calculation` driver | Live `/run` on docker-dev `:9200`: author a driver + tags in-tree, Test-connect green, author a calc tag | -| **3 — UNS rework** | Equipment reference-only, raw-tag multi-select picker | Live `/run`: reference raw tags into an equipment; display-name override shows | -| **4 — Address space** | Dual namespace, UNS→Raw delegation, raw-only binding | Live `/run` + Client.CLI: browse both namespaces, read/write through both, HistoryRead/alarm at raw visible via UNS | +| **1 — Schema + identity rewiring** | Greenfield EF model + migration + dev-rig re-seed; **RawPath identity contract** through `TagConfigIntent`-successor, all 8 drivers' resolvers (retire blob-fallback parsers), composer/artifact/`DriverHostActor`/`DraftValidator`/`UnsTreeService` rewired to the new shape; endpoint→`DeviceConfig` move incl. the 4 embedded-`Devices[]` drivers. Address space composes hierarchy-only/empty (**dark until Batch 4**) | build/test green incl. golden-corpus rewrite; migration applies; seed loads | +| **2 — Raw UI** | `/raw` project tree (lazy loading + new context-menu component), driver config/test modals, tag manual/CSV/browse (**depends on the universal `DiscoveryDriverBrowser` having landed pre-v3**; Batch 2 re-targets its commit to raw Tag rows + merged Driver+Device config), `Calculation` driver | Live `/run` on docker-dev `:9200`: author a driver + tags in-tree, Test-connect green, browse-commit raw tags from a discovery-capable driver, author a calc tag. **This gate proves authoring + probe only — no values are observable until Batch 4** | +| **3 — UNS rework** | Equipment reference-only, raw-tag multi-select picker, effective-name uniqueness, `{{equip}}` reference-relative resolution | Live `/run`: reference raw tags into an equipment; display-name override shows; deploy-gate rejects a reference collision | +| **4 — Address space** | Dual namespace, UNS fan-out registration, raw-only binding, alarm/event dual-notifier, historian dual-registration, sink-interface namespace discriminator + `DeferredAddressSpaceSink` forwarding (+ reflection-test extension) | Live `/run` + Client.CLI: browse both namespaces, read/write through both, HistoryRead + native alarm at raw visible via UNS, `{{equip}}` script resolves | Batches are sequential; batch 4 landing = **v3.0**. ## Testing strategy - **Unit** (xUnit + Shouldly): schema integrity rules (block delete-while-referenced, - non-empty folder delete), CSV round-trip, `DriverType` canonicalization, UNS-reference - resolution, `Calculation` driver value production. + non-empty folder delete, sibling-name uniqueness, effective-name collision), CSV + round-trip, `DriverType` canonicalization, RawPath resolution per driver, UNS-reference + resolution, `{{equip}}` reference-relative substitution + unresolved-ref rejection, + calc-tag cycle detection, `Calculation` driver value production. - **Integration** (`*.IntegrationTests`): address-space builder emits both namespaces with - correct NodeIds; UNS delegation resolves to the raw value source. + correct NodeIds; UNS node receives the raw value via fan-out; write via UNS NodeId reaches + the driver; native alarm event visible at both notifiers. - **Live verification per batch — non-negotiable.** This project has a repeated history of prod-inertness bugs that unit tests + review missed (e.g. the `DeferredAddressSpaceSink` forwarding trap). Every batch gets a docker-dev `/run` gate before it is "done." @@ -207,6 +455,22 @@ Batches are sequential; batch 4 landing = **v3.0**. - **Dangling reference guard** — raw tag delete blocked while referenced; UI names the equipment. +- **Rename cascade (the price of path-shaped identity)** — renaming a raw tag, or any + ancestor folder/driver/device/group, changes the RawPath and therefore: the `ns=Raw` NodeId + (client subscriptions to the old NodeId break), the default historian tagname (history + continues under a **new** tag unless a `historianTagname` override pins it), `ctx.GetTag` + literals in scripts (deploy-gate error for `{{equip}}` refs; silent `BadNodeIdUnknown` for + absolute RawPath literals), and external raw bindings (ScadaBridge). Mitigations: the UI + **warns on rename** when the tag is historized, UNS-referenced, or matched by a script + literal; historian continuity is achieved by setting `historianTagname` before renaming. + This is Kepware-equivalent behavior, accepted deliberately. + *Script-literal detection for the rename warning (decided):* a **substring scan of + `Script` bodies for the old RawPath** at rename time, service-level. The compose-time + dependency-ref index only exists per deploy (stale at authoring time), and a warning + tolerates false positives — the scan is cheap, always current, and needs no new + bookkeeping. `{{equip}}`-relative refs don't need scanning at all: they resolve through + `UnsTagReference` rows, so a rename can't silently strand them (the deploy gate catches + any resulting breakage). - **Datatype change on a referenced raw tag** — propagates to UNS (raw is truth); if it breaks a UNS consumer, that surfaces at browse/subscribe time (documented, not silently patched). @@ -215,18 +479,72 @@ Batches are sequential; batch 4 landing = **v3.0**. - **Capability-gated browse** — non-browsable drivers show a disabled action + tooltip, never a broken picker. - **Cross-cluster reference** — structurally impossible (the UNS picker is cluster-scoped). +- **UNS effective-name collision** — rejected at authoring; rename-induced collisions caught + by the `DraftValidator` deploy gate with a clear error naming both sources. ## Out of scope for v3.0 (follow-ups) - Pattern/CSV-based auto-linking of raw → UNS references. -- Live browse for the address-entry protocols (Modbus/S7/AB-CIP/AB-Legacy/TwinCAT/FOCAS) via - per-protocol template catalogs. +- Address-entry assistance for the genuinely non-browsable flat-address drivers + (Modbus/Modbus-RTU/S7/AB-Legacy) via per-protocol template catalogs. *(Narrowed from the + original follow-up: AbCip/TwinCAT/FOCAS gain live browse pre-v3 via the universal + `DiscoveryDriverBrowser` — see `docs/plans/2026-07-15-universal-discovery-browser-design.md`.)* - Optional Raw-namespace hiding toggle (security-sensitive deployments exposing only UNS). +## Appendix — CSV column dictionaries (decided 2026-07-15) + +Common columns (every driver): `Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, +PollGroup, IsHistorized, HistorianTagname, IsArray, ArrayLength, Alarm.AlarmType, +Alarm.Severity, Alarm.HistorizeToAveva, TagConfigJson`. The `Alarm.*` dotted columns are the +flattened native-alarm object (absent columns = no alarm); `TagConfigJson` is the fallback +for any key not covered by a typed column (every editor already preserves unknown keys, so +it round-trips safely). + +Per-driver typed columns (from the existing `TagConfigModel` scalar surfaces, minus +the keys v3 normalizes away — no `deviceHostAddress`, no PascalCase `FullName`): + +| Driver | Typed columns | +|---|---| +| Modbus | `Region, Address, ModbusDataType, ByteOrder, BitIndex, StringLength, Writable` | +| S7 | `Address, S7DataType, StringLength, Writable` | +| AbCip | `TagPath, AbCipDataType, Writable` | +| AbLegacy | `Address, AbLegacyDataType, Writable` | +| TwinCAT | `SymbolPath, TwinCATDataType, Writable` | +| FOCAS | `Address, FocasDataType` *(no `writable` key exists for FOCAS)* | +| OpcUaClient | `NodeId` | +| Galaxy | `AttributeRef` | +| Calculation | `ScriptId, ChangeTriggered, TimerIntervalMs` | + +Enum-typed columns accept the enum member names (case-insensitive), matching the editors' +string-name JSON convention. + +## Open items + +**None.** All seven review-era open items were resolved on 2026-07-15: + +| Item | Resolution | +|---|---| +| Historian tagname constraints vs RawPath | Live-probed vs the real AVEVA historian: **255-char limit**, path charset fully accepted, byte-exact round-trip; >255 ⇒ `DraftValidator` error prompting a `historianTagname` override (identity-contract section) | +| `NamespaceKind.Simulated` disposition | Verified vestigial (reserved enum + UI placeholder only); retired with the `Namespace` entity (data-model section) | +| SDK spike: multi-notifier event reporting | Native `AddNotifier` multi-root wiring confirmed from SDK source; duplicate-report fallback rejected (address-space section) | +| `Calculation` driver mini-design | Written: `docs/plans/2026-07-15-calculation-driver-mini-design.md` | +| Per-driver CSV column dictionaries | Decided (appendix above) | +| ScadaBridge cutover coordination | Sized (2 DB columns, `ns=2` index hard-coding) + planned (cross-repo paragraph) | +| Rename-warning scope | Authoring-time substring scan over `Script` bodies (edge-cases section) | + +Nothing gates any batch. The design is ready for implementation planning (tasks #7–#11). + ## Implementation tasks -- #7 — Raw/UNS v3 schema -- #8 — Global Raw project-tree AdminUI (`/raw`) -- #9 — UNS Equipment reference-only rework -- #10 — Dual-namespace address space + raw-only runtime binding +**Implementation plans written 2026-07-15** (master orchestration + one plan per batch, +sized for parallel agent execution with per-wave file ownership and live gates): +`2026-07-15-v3-implementation-plan.md` → +`…-v3-batch1-schema-identity-plan.md` / `…-v3-batch2-raw-ui-calculation-plan.md` / +`…-v3-batch3-uns-rework-plan.md` / `…-v3-batch4-address-space-plan.md`. + +- #7 — Raw/UNS v3 schema **+ RawPath identity rewiring** (re-scoped per Batch 1) +- #8 — Global Raw project-tree AdminUI (`/raw`) incl. context-menu + lazy-tree infra +- #9 — UNS Equipment reference-only rework + `{{equip}}` reference-relative resolution +- #10 — Dual-namespace address space + raw-only runtime binding + alarm/event/historian dual-surface - #11 — `Calculation` pseudo-driver for signal-level calc tags + (mini-design: `docs/plans/2026-07-15-calculation-driver-mini-design.md`) diff --git a/docs/plans/2026-07-15-universal-discovery-browser-design.md b/docs/plans/2026-07-15-universal-discovery-browser-design.md index 52a95c34..37a18e9b 100644 --- a/docs/plans/2026-07-15-universal-discovery-browser-design.md +++ b/docs/plans/2026-07-15-universal-discovery-browser-design.md @@ -127,12 +127,57 @@ narrow the driver config or use manual entry" rather than OOM on a 100 k-node ba capture on a short interval until the node set is non-empty and stable across two passes, bounded by the open-timeout — the same contract `DriverInstanceActor` honours at deploy. 5. `await driver.ShutdownAsync(...)` in a `finally` — the tree is fully captured; hold nothing - live. (No lazy expand ⇒ no need to keep the connection.) + live. (No lazy expand ⇒ no need to keep the connection.) **The shutdown itself is bounded** + (the R2-01 per-op-deadline rule, program doc §3.1 — no unbounded waits anywhere): it runs + under its **own 10 s linked CTS**, because a driver wedged during discovery may be equally + wedged in shutdown, and cleanup must not extend the open beyond open-timeout + that bound. + On shutdown timeout: log a warning, **abandon the instance** (drop the reference — never + block on it or rethrow), and still return / fail the open on the **discovery outcome** — + a hung shutdown neither fails a successful capture nor masks the real discovery error. 6. return `new CapturedTreeBrowseSession(capture.Root)`. -`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) succeeds **and** the -instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by the AdminUI to decide -whether to render the **Browse** button vs. manual entry, before any connect. +#### Capture concurrency — coalescing + global cap + +Every `OpenAsync` is heavy: it constructs, connects, and fully enumerates a **real driver +against the live device**. Repeated Browse clicks (or two operators picking against the same +controller) must not stack parallel symbol walks on one PLC. Two controls, both inside +`DiscoveryDriverBrowser`: + +- **In-flight coalescing**, keyed on `(driverType, hash of configJson)` (e.g. SHA-256 of the + caller-supplied JSON; `PatchForBrowse` is a pure function of `driverType`, so hashing the + pre-patch config is equivalent per key). A second open for the same key **awaits the same + capture task** rather than starting another walk; each awaiting caller then receives its + **own** `CapturedTreeBrowseSession` (own token, own TTL in the `BrowseSessionRegistry`) + over the **shared, immutable captured tree**. A failed capture fails every coalesced + awaiter with the same error. The coalescing map holds **in-flight captures only** — no + result caching: the entry is removed when the capture completes (success or failure), so + the next open for that key (including a Refresh, §4.3) triggers a fresh capture. +- **A global cap on simultaneous captures** — `MaxConcurrentCaptures = 4` (a const, like + `BrowserSessionService.PerCallTimeout`). Excess opens **queue** (semaphore, FIFO) and still + honour the caller's cancellation token while queued. Coalesced awaiters don't consume a + slot — only distinct in-flight captures count against the cap. + +`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) yields a non-null +instance **and** that instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by +the AdminUI to decide whether to render the **Browse** button vs. manual entry, before any +connect. Two hardening rules: + +- **`TryCreate` can throw, not just return null.** Factories parse `configJson` *inside* + `TryCreate`, so malformed/half-typed JSON (exactly what a picker holds mid-authoring) can + surface as an exception. `CanBrowse` must catch **any** exception → `false` — it is a UI + affordance gate and must never throw into the page. +- **Defensive teardown of the throwaway instance.** Whatever the outcome (not discovery-capable, + flag false, or an exception after construction), `CanBrowse` best-effort + `ShutdownAsync`s/disposes any instance it *did* create — bounded, swallow-all — so probing + never leaks driver instances. + +**Invariant the throwaway-instance pattern depends on:** driver constructors are +**connection-free** today — every driver in the tree connects in `InitializeAsync`, never in +its ctor. `CanBrowse` (construct → inspect → discard, potentially on every picker render) and +the capture path's construct-then-patch flow both rely on this. A future driver that opens a +device connection in its constructor would silently turn `CanBrowse` into a connect storm — +treat "no I/O in driver ctors" as a driver-authoring rule this browser now depends on (worth a +line in the program doc's cross-cutting rules when P1 lands). ### 4.3 `CapturedTreeBrowseSession : IBrowseSession` diff --git a/docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md b/docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md new file mode 100644 index 00000000..7e8d0679 --- /dev/null +++ b/docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md @@ -0,0 +1,228 @@ +# v3 Batch 1 — greenfield schema + RawPath identity rewiring + +**Branch:** `v3/batch1-schema-identity` +**Design authority:** `2026-07-15-raw-uns-two-subtree-v3-design.md` — §"v3 identity contract (RawPath)", §"Data model", §"Build plan" row 1. +**Master plan:** `2026-07-15-v3-implementation-plan.md` (conventions + gotchas are binding). +**End state:** the config schema and every identity seam speak RawPath; the solution builds and all tests pass; the address space composes hierarchy-only/empty (**deliberately dark until Batch 4**); docker-dev re-seeds and boots. + +## Scope guard + +- NO AdminUI feature work (Batch 2). AdminUI changes here are limited to *keeping it + compiling* against the new entity shapes (pages that authored the old shapes may be + temporarily stubbed with an "unavailable until Batch 2" banner — record each stub in the + PR description; Batch 2 owns their replacement). +- NO OPC UA node materialization changes beyond making the composer emit an empty/ + hierarchy-only composition (Batch 4 owns the dual namespace). +- Greenfield means greenfield: no data-migration code, no back-compat shims for old + `TagConfig` shapes, no blob-fallback retention "just in case." + +## The RawPath contract (what every package codes against) + +`RawPath = ////` — cluster-scoped, +slash-separated, built ONLY by the new `RawPaths` helper (never string-concatenated ad +hoc). Segments forbid `/` and leading/trailing whitespace (validated at authoring). +Case-sensitive ordinal comparison everywhere. It keys: driver wire-refs, the +`DriverHostActor` fan-out/write maps, historian default tagname + mux refs, native-alarm +`ConditionId`, `ctx.GetTag` paths, and (Batch 4) `ns=Raw` NodeIds. + +## Work packages + +### Wave A — foundations (2 agents, parallel, disjoint files) + +**B1-WP1 — Greenfield EF model + `V3Initial` migration + seeds** + +Files (all under `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/` unless noted): +- `Entities/`: new `RawFolder.cs`, `TagGroup.cs`, `UnsTagReference.cs`; reshape + `DriverInstance.cs` (add nullable `RawFolderId`; **drop `NamespaceId`**), `Device.cs` + (add `DeviceConfig` JSON; now universal — every driver ≥1 device), `Tag.cs` + (required `DeviceId` FK, nullable `TagGroupId`; **drop `EquipmentId` + `FolderPath`**; + **keep `WriteIdempotent`** — dropping it regresses the R2 resilience fix), + `Equipment.cs` (**drop the `DriverInstanceId`/`DeviceId` binding**). Delete + `Namespace.cs`, `EquipmentImportBatch.cs` (both classes), and + `Enums/NamespaceKind.cs`; delete `NamespaceKindCompatibility` + `AllowedNamespaceKinds` + from `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`. +- `OtOpcUaConfigDbContext.cs` — new DbSets; **all** uniqueness/integrity config inline in + `OnModelCreating` per the design's rules: sibling-name unique indexes at every raw + level (filtered indexes for nullable `ParentRawFolderId`/`RawFolderId`/`TagGroupId`), + `OnDelete(Restrict)` so raw-tag delete is blocked while a `UnsTagReference` points at + it and non-empty `RawFolder`/`TagGroup` deletes are blocked. +- **Migration strategy (decided): squash.** Delete the existing `Migrations/` folder and + scaffold a single `V3Initial` migration. Port the hand-written stored-proc migrations' + `Up` SQL (the `*_StoredProcedures.cs` / `*_ExtendComputeGenerationDiffWith*.cs` files) + into `V3Initial` verbatim — audit them first: any proc referencing dropped + columns/tables (`NamespaceId`, `Tag.EquipmentId`, `Tag.FolderPath`) must be rewritten + to the new shape, and `ComputeGenerationDiff` must diff the new tables + (`RawFolder`/`Device`/`TagGroup`/`UnsTagReference`). Greenfield = no production DB to + upgrade, so squashing is safe; the fixture DBs are dropped/recreated. +- Seeds: rewrite `docker-dev/seed/seed-clusters.sql` (+ anything in + `docker-dev/seed/entrypoint.sh` that names tables) and every `scripts/smoke/seed-*.sql` + to the new schema — each driver gets a folder (optional), a device row with + `DeviceConfig` (endpoint moved out of `DriverConfig` — coordinate the JSON shape with + B1-WP5's contracts commit), and raw tags with RawPath-consistent names. +- Tests: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/` — + `SchemaComplianceFixture`/`SchemaComplianceTests` updated to the new schema; new unit + tests for every integrity rule (delete-while-referenced blocked, non-empty folder/group + delete blocked, default-device-with-tags delete blocked, sibling-name uniqueness per + level, `/`-in-name rejected). + +DoD: migration applies to a fresh DB (`dotnet ef database update` via +`DesignTimeDbContextFactory`); Configuration project + tests compile and pass (other +projects may be red until Wave B/C — that's expected mid-batch; the *batch branch* is +only merged green). + +**B1-WP2 — `RawPaths` helper + `TagConfigIntent` successor + key normalization** + +Files: +- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — pure static: `Build(...)` + from segment lists, `Validate(segment)` (no `/`, no lead/trail whitespace, non-empty), + `Combine`, `TryParent`. This is the contracts-commit centerpiece — land it first. +- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs` — sheds the + FullName-derivation role: remove `FullName`/`ExplicitFullName` (the blob-as-identity + convention dies here); **keep** the alarm/historize/array parsing + (`TagAlarmIntent`, `ParseAlarm`/`ParseHistorize`/`ParseArray`) — those remain real + `TagConfig` concerns. Callers to sweep (grounded list): `DraftValidator` (Galaxy + ExplicitFullName rule → deleted; replaced by B1-WP6 rules), + `Core/OpcUa/EquipmentNodeWalker.cs`, `OpcUaServer/AddressSpaceComposer.cs` + + `AddressSpaceChangeClassifier.cs`, `AdminUI/ScriptAnalysis/IScriptTagCatalog.cs`, + `AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs`, + `Runtime/Drivers/DeploymentArtifact.cs`. +- Key normalization (greenfield, decided in the design): OpcUaClient's PascalCase + `"FullName"` → `"nodeId"`; Galaxy's → `"attributeRef"`; the `deviceHostAddress` key + (AbCip/AbLegacy/TwinCAT/FOCAS) is **dropped** (the tag's `DeviceId` FK owns placement). + Update `OpcUaClientTagConfigModel.cs` (AdminUI) and the two browsers' commit paths + (`Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs`, + `Driver.Galaxy.Browser/GalaxyBrowseSession.cs`) to emit the new keys. +- Tests: rewrite `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs`; + new `RawPathsTests`; delete + `Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs` and + `Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs` (superseded — B1-WP6 + replaces them with RawPath-shaped corpus tests). + +### Wave B — per-driver resolver rework (up to 9 agents, parallel, one per driver + exemplar-first) + +**B1-WP3 — `EquipmentTagRefResolver` re-keying + blob-fallback retirement** + +Shared seam first (the wave's contracts commit, done by the coordinator or the exemplar +agent): `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs` — the +`_byName` branch becomes the primary and only path: each deploy hands the driver its +authored raw tags keyed by RawPath (ref string) → `TagConfig`-derived definition. The +`_cache.GetOrAdd(fullReference, _parseRef)` blob-parse fallback is **deleted**; a miss is +a miss (`TryResolve` false → driver logs + Bad quality), never a parse attempt. + +Then **exemplar: Modbus** (one agent, reviewed before fan-out so the pattern is fixed): +- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs` — + becomes a pure `TagConfig JSON → definition` mapper invoked at deploy-time table build + (no more "is this ref a blob?" heuristics). Rename to `ModbusTagDefinitionFactory` (or + similar) to make the retirement visible. +- Driver + tests updated to resolve by RawPath. + +Then **fan-out, one agent per driver** (disjoint project directories — fully parallel): +S7 (`S7EquipmentTagParser.cs` — the leading-`{` blob convention dies), AbCip, AbLegacy, +TwinCAT, FOCAS (same shape as exemplar); **OpcUaClient** (no static parser — seam is +`Driver.OpcUaClient.Contracts/NamespaceMap.cs` + `OpcUaClientDriver.cs`; the definition's +address now reads `TagConfig.nodeId`); **Galaxy** (seam is +`Driver.Galaxy/Browse/GalaxyDiscoverer.cs` + `Browse/AlarmRefBuilder.cs`; address reads +`TagConfig.attributeRef`; note `AlarmRefBuilder` feeds `ConditionId` — under v3 that is +the RawPath, keep both directions consistent). + +Each driver agent's DoD: driver project + its unit tests green with refs = RawPaths and +definitions sourced from the authored-tag table; zero references to +`TagConfigIntent.FullName` remain in the driver. + +**B1-WP5 — endpoint → `DeviceConfig` move (runs parallel with WP3; owns different files)** + +- Contracts: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs` reworked — + `DeviceConfig` JSON now carries host/endpoint + per-device settings for **all** drivers. +- The 4 multi-device drivers: remove the embedded `Devices[]` list from + `ModbusDriverOptions.cs`(*), `S7DriverOptions.cs`(*), `AbCipDriverOptions.cs`, + `AbLegacyDriverOptions` file, `TwinCATDriverOptions.cs`, `FocasDriverOptions` file — + (*grounding note: Modbus and S7 options also carry `Devices` lists — treat all six + options classes the same way: `DriverConfig` keeps protocol/channel-level settings only; + per-device settings move to `DeviceConfig`). Single-endpoint drivers (OpcUaClient + `EndpointUrl`, Galaxy gateway address, S7 host if single) move their endpoint into the + auto-created default device's `DeviceConfig`. +- Runtime spawning: `Runtime/Drivers/DriverInstanceActor.cs` + the artifact device-host + map in `Runtime/Drivers/DeploymentArtifact.cs` — driver instantiation now merges + `DriverConfig` + per-device `DeviceConfig` rows (this merged shape is also what Batch + 2's browse modal passes as `configJson` — get the merge helper into Commons so both + reuse it: `DriverDeviceConfigMerger`). +- Golden corpus: rewrite + `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs` + + `TagConfigCorpusParityTests.cs` to the v3 shapes (no `deviceHostAddress`, no + PascalCase `FullName`, per-device configs separate). + +Coordination rule: WP5 and WP3 both touch driver projects. Split ownership per file: +WP3 owns `*EquipmentTagParser.cs`/resolver/driver read-path files; WP5 owns +`*DriverOptions.cs`/factory/spawn-path files. Where one file carries both (a driver +factory that parses options AND builds the tag table), WP5 waits for that driver's WP3 +agent to merge first — the coordinator sequences per-driver. + +### Wave C — pipeline integration (2 agents, then 1 integrator) + +**B1-WP4 — composer / artifact / actors / validator / services rewiring** + +- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceChangeClassifier.cs`) — composes + from the new schema; emits a hierarchy-only/empty composition (folders exist as data; + **no variable nodes materialize** — Batch 4). Keep the alarm-intent parse (it flows to + the artifact) but drop equipment-tag NodeId composition. +- `Runtime/Drivers/DeploymentArtifact.cs` — flatten the new shape: per-driver authored + raw tags keyed by RawPath (feeding the WP3 resolvers), device rows with merged config + (WP5), historized set keyed by RawPath (effective historian tagname = override else + RawPath). +- `ControlPlane/AdminOperations/ConfigComposer.cs` (`SnapshotAndFlattenAsync`) — snapshot + the new tables into the sealed blob; `RevisionHash` inputs updated. +- `Runtime/Drivers/DriverHostActor.cs` — the map keys change meaning, not shape: + `_nodeIdByDriverRef` / `_driverRefByNodeId` / `_alarmNodeIdByDriverRef` / + `_driverRefByAlarmNodeId` now carry RawPath as the ref string. Rename the tuple member + `FullName` → `RawPath` so no stale semantics survive. NodeId sets stay empty until + Batch 4 (the maps still exist; mux forwarding still runs). +- `Configuration/Validation/DraftValidator.cs` + `DraftSnapshot(Factory)` — new rules: + raw-name charset (no `/`, no lead/trail whitespace) at every level; **historized-tag + effective tagname ≤ 255 chars** (live-verified AVEVA limit) → deploy error prompting a + `historianTagname` override; retired rules deleted (Galaxy ExplicitFullName, equipment + NodeId collision in its old form, namespace-binding checks). +- `AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs`, `Components/Pages/Uns/*`, + `Components/Shared/Uns/TagModal.razor` etc. — compile-only pass: the UNS tree keeps + Area/Line/Equipment; the Tags tab and TagModal driver-binding paths are stubbed behind + an "unavailable until v3 Batch 2/3" banner (list every stub in the PR). +- `ControlPlane/AdminOperations/AdminOperationsActor.cs` — per-tag TagConfig validation + loop updated (no FullName checks; alarm/historize checks retained). + +**B1-WP6 — test-suite + harness sweep** + +- Rewrite/replace the corpus tests deleted in WP2 with RawPath equivalents (resolution + per driver, artifact parity: `DeploymentArtifact*ParityTests.cs`). +- `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActor*Tests.cs`, + `DriverInstanceActor*Tests.cs`, `VirtualTags/*Tests.cs` — re-key refs to RawPaths. +- 2-node harness `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/` — seed shapes + updated (`EquipmentNamespaceMaterializationTests.cs` will assert the *dark* address + space this batch: hierarchy nodes only / no variables; mark the full assertion as + Batch-4-pending with a skip-reason constant, don't delete it). +- `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/` — ConfigComposer/deploy-path + shapes. + +## Wave schedule + parallelization + +| Wave | Packages | Agents | Parallel-safe because | +|---|---|---|---| +| A | WP1 ∥ WP2 | 2 | Disjoint: Configuration project vs Commons/driver-editor files. Coordinator lands `RawPaths.cs` + reshaped-entity contracts commit first | +| B | WP3 exemplar (Modbus) → review → WP3 fan-out (7 drivers) ∥ WP5 | up to 8 | Per-driver project directories are disjoint; WP3/WP5 per-file ownership rule above | +| C | WP4 → WP6 | 2 sequential (WP6 can start its mechanical re-keying while WP4 finishes, in a separate worktree, rebasing before merge) | WP4 is the single integration point — one agent only | + +All agents in worktrees; merge order A → B → C; coordinator builds+tests the batch branch +after every merge. + +## Batch verification gate + +1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` and `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green + (macOS-safe suites), plus the DB-backed `Configuration.Tests` against + `10.100.0.35,14330`. +2. Fresh-DB migration proof: point `OTOPCUA_CONFIG_CONNECTION` at a scratch DB, + `dotnet ef database update`, then run `SchemaComplianceTests`. +3. docker-dev: rebuild the migrator image + **both** central nodes, re-seed + (`docker-dev/seed/`), `docker compose up -d`; both nodes reach Running; seeded drivers + show Connected against the `10.100.0.35` fixtures (`lmxopcua-fix up modbus standard` + etc.); AdminUI loads (stubbed pages show their banners, nothing crashes). +4. Explicitly assert the dark address space: Client.CLI + `browse -u opc.tcp://localhost:4840 -r -d 3` shows hierarchy/no tag variables — and + the PR description says so, so nobody files it as a bug. diff --git a/docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md b/docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md new file mode 100644 index 00000000..aadbfefe --- /dev/null +++ b/docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md @@ -0,0 +1,193 @@ +# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver + +**Branch:** `v3/batch2-raw-ui` (opened from master after Batch 1 merges) +**Design authority:** v3 design §"AdminUI — the Raw project tree (`/raw`)", §"Calculated tags", the CSV appendix; `2026-07-15-calculation-driver-mini-design.md` (complete spec for B2-WP7); `2026-07-15-universal-discovery-browser-design.md` §11 (B2-WP6 only). +**Inputs from Track 0:** T0-1 `ContextMenu`, T0-2 CSV parser, T0-3 `DriverTypeNames`, T0-4 Calculation evaluator core — merge them onto this branch first if they haven't reached master. +**End state:** everything in the Raw tree is authorable end-to-end on docker-dev (tree, drivers, devices, tag-groups, tags via manual/CSV/browse, calc tags), Test-connect works, deploys succeed. **No live values are observable — Batch 4.** + +## Work packages + +### Wave A — tree + data service (2 agents) + +**B2-WP1 — `IRawTreeService` + lazy tree data layer** + +- New `AdminUI/Uns/IRawTreeService.cs` + `RawTreeService.cs` (peer of + `UnsTreeService`): cluster-rooted queries returning children per level + (Folder/Driver/Device/TagGroup/Tag), **paged/lazy** (per-device tag counts can be + large), plus mutation methods (create/rename/delete per node type, move-into-folder) + enforcing the integrity rules with user-readable errors (delete-blocked messages name + the referencing equipment — query `UnsTagReference` joins). +- Rename methods are where the **rename warning** hooks (Batch 3 adds the script-scan; + this batch wires the seam: rename returns a warnings list the UI shows — historized / + UNS-referenced checks are already answerable from this batch's schema). +- Unit tests against the EF model (in-memory + the DB-backed compliance suite pattern). + +**B2-WP2 — `/raw` page + tree component** + +- New `Components/Pages/Raw/GlobalRaw.razor` (`/raw` route, nav entry next to `/uns`) + + `Components/Shared/Raw/RawTree.razor` — modeled on `GlobalUns`/`UnsTree`/`UnsNode`, but + **actually exercising the lazy plumbing** (`HasLazyChildren`/loading flags exist in + `UnsNode.cs` and were never used; reuse the pattern or make a `RawNode` twin). +- Context menus per node type via T0-1 `ContextMenu` (menu items exactly as the design's + list: Folder/Driver/Device/TagGroup/Tag), incl. the "⋯" fallback affordance. +- Modals are stubs in this wave (each menu action opens a placeholder) — Waves B/C fill + them. This keeps WP2 mergeable early so later packages build on a rendering tree. +- Live-check on docker-dev before merge (no bUnit — the tree, lazy expansion, and menu + must be seen working). + +### Wave B — driver/device/tag authoring modals (3 agents, parallel) + +**B2-WP3 — driver + device config modals (the page-to-modal refactor)** + +- Refactor the 8 typed driver pages + (`Components/Pages/Clusters/Drivers/{Modbus,AbCip,AbLegacy,S7,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor`) + into embeddable form bodies: extract each page's form content into + `Components/Shared/Drivers/Forms/DriverForm.razor`; what stays behind is the + `@page` route + `EditForm` + ClusterNav + DB load + navigation. New + `DriverConfigModal.razor` hosts a form body by driver type (dispatch via T0-3 + `DriverTypeNames`). Keep the old routed pages working during v3 (they're deleted only + when `/raw` fully replaces them — end of this batch, see WP8). +- **Device modal (new):** with Batch 1's endpoint→`DeviceConfig` move, the multi-device + drivers' embedded device collection editors (`CollectionEditor.razor` usage inside the + pages) become a per-Device config modal editing `DeviceConfig` JSON via typed + per-driver device forms. Single-endpoint drivers' default device gets the same modal + (endpoint field lives here now). +- Test-connect inside the modal reuses `DriverTestConnectButton` + the `IDriverProbe` + path (transient config, no persistence) — probes now need the **merged + Driver+Device config** (Batch 1's `DriverDeviceConfigMerger`). +- **Enum-serialization guard:** every form/probe serialization site uses + `JsonStringEnumConverter` (the systemic FB-9/FB-10 bug class); add a round-trip test + per driver form model. + +**B2-WP4 — manual tag entry + tag edit modal** + +- `Add tags ▸ Manual entry` on Device/TagGroup: grid of name, datatype, access, + `WriteIdempotent`, poll group + the driver-typed `TagConfig` editor per row (reuse + `Components/Shared/Uns/TagEditors/*` via `TagConfigEditorMap` — re-keyed to + `DriverTypeNames` in WP8). Single-tag Edit modal for the Tag node reuses the same + editor shell. +- Name validation inline (`RawPaths.Validate` — no `/`, no lead/trail whitespace) + + sibling-uniqueness error surfaced from the service. +- New editor registration for `Calculation` (see WP7): `CalculationTagConfigModel` + (`scriptId`, `changeTriggered`, `timerIntervalMs`) + editor razor reusing the + VirtualTagModal's script dropdown/"New script"/inline Monaco panel components. + +**B2-WP5 — CSV import/export** + +- Uses T0-2 parser. Staged flow (all new code — the old `EquipmentImportBatch` staged + tables were orphaned dead code and are already dropped): upload → parse → **review + grid** (per-row validation verdicts) → commit. +- Columns per the design appendix: common + `Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized, + HistorianTagname, IsArray, ArrayLength, Alarm.AlarmType, Alarm.Severity, + Alarm.HistorizeToAveva, TagConfigJson` + the per-driver typed columns table (Modbus + `Region, Address, ModbusDataType, ByteOrder, BitIndex, StringLength, Writable`; S7 + `Address, S7DataType, StringLength, Writable`; AbCip `TagPath, AbCipDataType, + Writable`; AbLegacy `Address, AbLegacyDataType, Writable`; TwinCAT `SymbolPath, + TwinCATDataType, Writable`; FOCAS `Address, FocasDataType`; OpcUaClient `NodeId`; + Galaxy `AttributeRef`; Calculation `ScriptId, ChangeTriggered, TimerIntervalMs`). + Enum columns accept member names case-insensitively. `TagGroupPath` auto-creates + nested groups. `TagConfigJson` is the fallback for unmapped keys and **merges under** + typed columns (typed column wins on conflict — document in the review grid). +- Export produces the same shape (round-trip property test: export → import → identical + tag set). +- Mapping layer: per-driver `CsvColumnMap` derived from the `TagConfigModel` + scalar surfaces — put it next to the models in `AdminUI/Uns/TagEditors/` so model and + map evolve together; a reflection test asserts every typed column matches a model + property. + +### Wave C — browse + Calculation + canonicalization (3 agents, parallel) + +**B2-WP6 — browse re-target (GATED: requires the universal `DiscoveryDriverBrowser` merged pre-v3)** + +Precondition check first: `IDriverBrowser` two-tier resolution + `DiscoveryDriverBrowser` ++ `ITagDiscovery.SupportsOnlineDiscovery` exist (they do NOT as of 2026-07-15). If absent, +this package detaches to a follow-up PR and the batch gate skips its leg. + +- `Browse device…` on Device/TagGroup opens the browse modal reusing the session/tree + layer as-is (`IBrowseSession`, `BrowseSessionRegistry`, `BrowserSessionService`, + `DriverBrowseTree.razor`); enable/disable per the two-tier gate (bespoke browser → + universal when `SupportsOnlineDiscovery` → grayed out + tooltip). +- **Commit contract (v3):** multi-selected leaves → raw `Tag` rows under the target + Device/TagGroup — `Name` from browse name, `DataType` from `DriverAttributeInfo`, the + leaf's driver reference written into the driver-typed `TagConfig` **address field** + (`nodeId` / `attributeRef` / the protocol address — NOT any identity key). Opt-in + "create matching tag-groups" toggle mirrors browse folders onto nested `TagGroup`s. +- **Config input:** pass the merged Driver+Device config (`DriverDeviceConfigMerger`) as + the browser's `configJson` (the universal browser dials a real ephemeral driver + instance and needs the device endpoint). +- Name-collision note: the browser side must not reuse the name + `CapturingAddressSpaceBuilder` — Runtime owns it + (`Runtime/Drivers/CapturingAddressSpaceBuilder.cs`). + +**B2-WP7 — `Calculation` driver (execute `2026-07-15-calculation-driver-mini-design.md` §§1–8 verbatim)** + +- Project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` around the T0-4 evaluator: + `CalculationDriver : IDriver, ISubscribable, IReadable` (not `IWritable`, not + `ITagDiscovery`); the new **`IDependencyConsumer`** capability interface in + `Core.Abstractions` (exact shape in mini-design §3); `DriverHostActor` wiring (~30 + lines): when a spawned driver implements it, spawn a mux-adapter child that + `RegisterInterest(driver.DependencyRefs, Self)` on `DependencyMuxActor` and forwards + `DependencyValueChanged` into `OnDependencyValue`; re-register on every apply. + **Register-AND-consume trap:** the DoD includes a Runtime test proving a calc driver's + deps actually flow (not just that the interface exists). +- Triggers: change-gate à la `VirtualTagActor` (no publish until all deps seen; dedupe) + + timer via the dormant `TimerTriggerScheduler` revived inside the driver. +- Deploy gates in `DraftValidator`: `scriptId` existence in the draft generation; Tarjan + cycle gate over calc→calc edges via the dormant `DependencyGraph` (cross-driver refs + are terminal nodes). Compile deliberately NOT hard-gated (VT parity). +- Error semantics: Bad + last-known value once per Good→Bad transition + `ScriptLogEntry` + on `script-logs`; recovery force-published; historized Bad = `BadInternalError`. +- Registration: `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` + + `AddOtOpcUaDriverProbes` (probe = config-parse only, never throws); auto-created + default `Engine` device; `DriverTypeNames.Calculation`. +- Tests per mini-design §8 (unit + the 2-node harness integration case). + +**B2-WP8 — `DriverTypeNames` rewire + old-page retirement** + +- Re-key the four drifted dispatch maps to T0-3 constants: + `DriverEditRouter._componentMap` (fixes `TwinCat`→`TwinCAT`, `Focas`→`FOCAS`), + `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `TagConfigValidator.cs`, and + `ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs`. Add `Calculation` + entries where applicable. +- Once `/raw` covers authoring end-to-end (this wave), retire the routed + `/clusters/{id}/drivers` flow: `DriverTypePicker`, `DriverEditRouter`, the 8 page + shells (their extracted form bodies live on in the modals). Nav + any deep links + updated. + +## Wave schedule + +| Wave | Packages | Agents | Notes | +|---|---|---|---| +| 0 | Merge Track 0 outputs onto the branch | — | T0-1..T0-4 | +| A | WP1 ∥ WP2 | 2 | Service vs components — disjoint; WP2 stubs modals | +| B | WP3 ∥ WP4 ∥ WP5 | 3 | Disjoint: driver forms vs tag editors/grid vs CSV. All three consume WP1/WP2 | +| C | WP6 ∥ WP7 ∥ WP8 | 3 | WP6 gated on the universal browser; WP7 is mostly Runtime/driver-side (disjoint from UI packages); WP8 last-merged in the wave (it deletes pages others may still reference — coordinator merges WP8 after WP3/WP6) | + +## Batch verification gate (live `/run` on docker-dev — authoring + probe only) + +Rebuild **both** central nodes, `docker compose up -d`, bring up fixtures +(`lmxopcua-fix up modbus standard`, `lmxopcua-fix up s7 s7_1500`). Then on +`http://localhost:9200` (login disabled): + +1. `/raw`: create Folder → Modbus driver (form body renders, enum fields correct) → + Device with endpoint `10.100.0.35:5020` in `DeviceConfig` → **Test connect green**. +2. TagGroup → Manual entry: add 3 tags incl. one historized with a >255-char effective + tagname → **deploy blocked by DraftValidator naming the tag**; shorten/override → + deploy succeeds (POST `/api/deployments`, `X-Api-Key`). +3. CSV: export the device's tags, delete one, re-import the file → review grid shows the + re-add, commit restores it. Import a file with a bad enum value → row-level error in + the grid, no partial commit. +4. Browse (if WP6 landed): browse an AbCip/OpcUaClient device, multi-select leaves with + "create matching tag-groups" → raw tags land under mirrored groups with correct + DataType + address field. +5. Calculation: create a `Calculation` driver → author a calc tag whose script reads the + Modbus tag's RawPath → deploy succeeds; author a 2-cycle (A reads B, B reads A) → + **deploy blocked naming the cycle members**. (Computed values are NOT visible yet — + Batch 4; the script-log page must stay silent, no crash loops in + `docker compose logs`.) +6. Rename a driver → the UI warning seam fires for a historized tag beneath it. +7. Confirm the retired `/clusters/{id}/drivers` routes are gone from nav and 404/redirect + cleanly. + +Record each step's evidence in the PR. diff --git a/docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md b/docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md new file mode 100644 index 00000000..caab677b --- /dev/null +++ b/docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md @@ -0,0 +1,112 @@ +# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}` reference-relative resolution + +**Branch:** `v3/batch3-uns-rework` (from master after Batch 2 merges) +**Design authority:** v3 design §"UNS Equipment — reference-only", §"Scripting — paths and `{{equip}}` under v3", §"Error handling / edge cases" (effective-name collision, rename-warning scan). +**End state:** Equipment holds `UnsTagReference` rows instead of authored tags; scripts resolve `{{equip}}/` through references; every collision/unresolved case is a clear deploy error. Address space still dark for values (Batch 4), so verification is authoring + deploy-gate level. + +## Work packages + +### Wave A (3 agents, parallel) + +**B3-WP1 — Equipment Tags tab → reference list + raw-tag picker** + +- `Components/Pages/Uns/EquipmentPage.razor` Tags tab: replace the Batch-1-stubbed + authored-tag flow with a `UnsTagReference` list — columns: effective name, raw path, + inherited datatype/access (read-only, from the raw tag), display-name override, + remove. `TagModal.razor`'s driver-binding path is deleted (VirtualTag/ScriptedAlarm + modals untouched). +- **"+ Add reference"** opens a raw-tree picker modal reusing Batch 2's `RawTree` + component in picker mode (multi-select checkboxes on Tag leaves; Device/TagGroup + select-all), **scoped to the equipment's cluster** (cross-cluster structurally + impossible — the service query is cluster-filtered, not just the UI). +- `ImportEquipmentModal.razor` + `Uns/EquipmentInput.cs` + `UnsTreeService.ImportEquipmentAsync`: + drop the `DriverInstanceId` column (equipment no longer carries a driver). +- Service mutations in `UnsTreeService` (+ `IUnsTreeService`): add/remove references, + set override — each enforcing WP2's uniqueness check and returning readable errors. + +**B3-WP2 — effective-name uniqueness (authoring + deploy gate)** + +- Rule: within an equipment, effective name (`DisplayNameOverride` else raw `Name`) is + unique across **references, VirtualTags, and ScriptedAlarms**. +- Authoring-time: service-level check in every mutation that can collide (add reference, + set override, add/rename VirtualTag or ScriptedAlarm). +- Deploy-time: `DraftValidator` gains the same rule over the draft snapshot — this is + what catches **rename-induced** collisions (a raw rename the authoring check never + saw); the error names both colliding sources and the equipment. +- `DraftSnapshotFactory` extended to load `UnsTagReference` + raw-tag names into the + snapshot. +- Unit tests: authoring rejection, deploy rejection post-rename, override-vs-VT clash, + case-sensitivity pinned (ordinal, matching NodeId semantics). + +**B3-WP3 — rename-warning script scan** + +- Extend Batch 2's rename-warning seam in `RawTreeService`: on rename of a tag or any + ancestor, compute affected RawPaths (prefix scan) and warn when a tag is (a) + historized without a `historianTagname` override (history forks), (b) UNS-referenced + (names the equipment), (c) matched by a **substring scan of `Script` bodies for the + old RawPath** (service-level, tolerates false positives — decided in the design; no + new bookkeeping). `{{equip}}`-relative refs need no scan (they resolve through + references; the deploy gate catches breakage). +- UI: warning list in the rename confirm dialog; rename proceeds on confirm + (Kepware-equivalent, deliberate). + +### Wave B (1 agent — single integration point) + +**B3-WP4 — `{{equip}}` reference-relative resolution** + +- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs`: + **delete `DeriveEquipmentBase`** (shared first-dot prefix of child FullNames — cannot + survive reference-only equipment). `SubstituteEquipmentToken` is replaced by + **per-reference resolution**: `{{equip}}/` → look up the equipment's + `UnsTagReference` rows by effective name → substitute the backing RawPath. Token + syntax note: today's derivation used a **dot** joint (`{{equip}}.X`); v3's is + slash-shaped (`{{equip}}/`) per the design — update `ExtractDependencyRefs` / + `ExtractAlarmDependencyRefs` and the `EquipToken` handling accordingly, and pin the + new syntax in tests. +- The **same two compose seams** substitute as today: + `OpcUaServer/AddressSpaceComposer.cs` (~line 210 branch) and + `Runtime/Drivers/DeploymentArtifact.cs` (`SubstituteEquipmentToken` call, line ~563) — + both now receive the equipment's reference map instead of a derived base prefix. +- **Unresolved `` = deploy-time validation error** (in `DraftValidator`, where + the reference map + script sources are both in the snapshot), naming the script, the + equipment, and the missing ref name — replacing today's silent null-base + no-substitution. Alarm `{TagPath}` message tokens follow the same rule. +- Monaco/editor parity: `AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` — the + `{{equip}}` completion branch (lines ~176–182) now completes **reference effective + names** for the equipment context; the diagnostics branch (~280–284) flags unresolved + refs identically to the deploy gate (editor accepts ⇔ publish accepts — the + established invariant). `IScriptTagCatalog` gains the per-equipment reference list; + absolute-path completions list RawPaths (already moved in Batch 1's catalog rework — + verify, don't assume). +- Tests: substitution unit tests (resolved, unresolved→error, override-named ref, + alarm-token), composer/artifact parity tests, ScriptAnalysis completion/diagnostic + tests. + +## Wave schedule + +| Wave | Packages | Agents | Parallel-safe because | +|---|---|---|---| +| A | WP1 ∥ WP2 ∥ WP3 | 3 | WP1 owns Razor + service mutation methods; WP2 owns validator/snapshot + service *check* helpers (shared file `UnsTreeService.cs` — WP1 owns it, WP2 delivers its check as a separate injectable `EffectiveNameGuard` consumed by WP1); WP3 owns `RawTreeService` warning code | +| B | WP4 | 1 | Touches Commons + composer + artifact + validator + ScriptAnalysis — single integration agent, rebased on Wave A | + +## Batch verification gate (live `/run` on docker-dev) + +Rebuild **both** centrals; fixtures up; Batch 2's raw tags present (re-author or re-seed). + +1. Equipment page: "+ Add reference" picker is cluster-scoped (raw tags of another + cluster absent), multi-select pulls 3+ tags at once; rows show raw path + inherited + type/access; set a `DisplayNameOverride` → effective name updates. +2. Collision: add a reference whose effective name equals an existing VirtualTag → + **authoring rejected** with both sources named. Then force a rename-induced clash + (rename the raw tag in `/raw` so two references collide) → authoring allowed at + rename, **deploy blocked** by `DraftValidator` naming both. +3. `{{equip}}`: author a VirtualTag script using `ctx.GetTag("{{equip}}/")` — + Monaco completes the reference names and shows no diagnostic; deploy succeeds. + Misspell the ref → Monaco diagnostic AND deploy error agree (same message family). +4. Rename warning: rename a raw tag that is historized + referenced + named in a script + literal → confirm dialog lists all three warnings; renaming an unrelated tag warns + nothing. +5. ImportEquipmentModal: import runs without the driver column. + +Deploys via `POST /api/deployments` (`X-Api-Key`). Values still dark — assert nothing +about live data. Record evidence in the PR. diff --git a/docs/plans/2026-07-15-v3-batch4-address-space-plan.md b/docs/plans/2026-07-15-v3-batch4-address-space-plan.md new file mode 100644 index 00000000..0fcb2fb3 --- /dev/null +++ b/docs/plans/2026-07-15-v3-batch4-address-space-plan.md @@ -0,0 +1,170 @@ +# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0) + +**Branch:** `v3/batch4-address-space` (from master after Batch 3 merges) +**Design authority:** v3 design §"OPC UA address space + runtime binding" (including the resolved multi-notifier SDK mechanism — the `AddNotifier` code block is normative), §"Error handling / edge cases". +**End state:** both namespaces live; every value has exactly one source (the raw node's publish path) fanned to both NodeIds; writes, history, alarms, and scripts all work through either namespace. This batch landing = **v3.0**. + +**This is the batch where the forwarding trap lives.** Every sink-interface change MUST +forward through `DeferredAddressSpaceSink` and extend +`tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs` +— that is part of WP2's definition of done, not an afterthought. + +## Namespace + NodeId scheme (contracts commit — coordinator lands first) + +- Two namespace URIs (constants next to the current + `OtOpcUaNodeManager.DefaultNamespaceUri = "https://zb.com/otopcua/ns"`, which they + replace): **`https://zb.com/otopcua/raw`** and **`https://zb.com/otopcua/uns`**. +- `ns=Raw` NodeIds: `s=`. `ns=UNS` NodeIds: + `s=///`. +- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs` (raw + UNS path builders, + consuming `RawPaths`); `EquipmentNodeIds.cs` (`{EquipmentId}/{FolderPath}/{Name}` + logical-id scheme) is **retired** — delete it and sweep callers + (`EquipmentNodeWalker`, composer, tests). +- Sink discriminator (decided here, used by WP2/WP3): sink methods carry an + `AddressSpaceRealm` enum (`Raw` | `Uns`) rather than parsing namespace out of NodeId + strings — explicit beats inferred at every call site. + +## Work packages + +### Wave A — composition + node manager (2 agents) + +**B4-WP1 — composer/planner emit both subtrees** + +- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceComposition`, + `AddressSpaceChangeClassifier`): un-darken. Compose the Raw subtree + (Cluster→Folder→Driver→Device→TagGroup→Tag) from the schema — folders/drivers/devices/ + groups as Object/Folder nodes, tags as Variables keyed `(realm=Raw, s=)` — + and the UNS subtree (Area/Line/Equipment folders; each `UnsTagReference` a Variable + keyed `(realm=Uns, s=/)`) carrying its backing RawPath + + an `Organizes` reference UNS→Raw. Native-alarm plans attach at the **raw** tag + (ConditionId = RawPath) and carry the list of referencing equipment paths. +- `AddressSpacePlan.cs` (`AddressSpacePlanner.Compute`) diffs per realm; a raw rename + manifests as remove+add in Raw **and** re-point in UNS (reference row unchanged, backing + NodeId changed) — pin this in planner tests. +- Reactivate the Batch-1-skipped harness assertion + (`EquipmentNamespaceMaterializationTests` skip-reason constant) with the new dual-tree + expectations. + +**B4-WP2 — node manager dual-namespace + sink surface (owns the forwarding trap)** + +- `OpcUaServer/OtOpcUaNodeManager.cs`: register both namespace URIs; node creation/ + `EnsureVariable`/`CreateVariable` take the realm; `_historizedTagnames`, + `_notifierFolders`, and the write hook wiring become realm-aware. +- Sink interfaces `Commons/OpcUa/IOpcUaAddressSpaceSink.cs` + + `ISurgicalAddressSpaceSink.cs`: add the `AddressSpaceRealm` discriminator (or + realm-qualified node descriptors) to every method that names nodes; update + `SdkAddressSpaceSink`, **forward every change through `DeferredAddressSpaceSink`**, and + extend `DeferredSinkForwardingReflectionTests` so an unforwarded method fails CI. +- HistoryRead access bits: the UNS reference node registers the **same** historian + tagname in `_historizedTagnames` (both NodeIds → one tagname; the mux + `HistorizedTagRef` stays single, keyed by RawPath — `AddressSpaceApplier.FeedHistorizedRefs` + emits raw refs only, no doubles). + +### Wave B — runtime binding + writes (1 agent; the single most delicate package) + +**B4-WP3 — raw-only binding, UNS fan-out, write routing** + +- `Runtime/Drivers/DriverHostActor.cs`: on apply, register the raw NodeId AND every + referencing UNS NodeId against the same `(DriverInstanceId, RawPath)` in + `_nodeIdByDriverRef` (the map is already 1:N — this is new entries, not new machinery); + `_driverRefByNodeId` gains the UNS NodeId → same ref inverse. Alarm maps + (`_alarmNodeIdByDriverRef`/`_driverRefByAlarmNodeId`) likewise carry the equipment + notifier NodeIds. `ForwardToMux` unchanged (mux keys stay RawPath-single). +- Write path: a write to a UNS NodeId resolves through `_driverRefByNodeId` to the same + driver ref — same `WriteOperate` gating, same `RouteNodeWrite` flow. `OnWriteValue` + stays fire-and-forget under the node-manager lock. **Write-outcome self-correction + (#5) must keep working through both NodeIds**: a failed device write reverts BOTH node + states (they share the stored value only via the fan-out — the revert is a publish, so + it fans automatically; add the regression test anyway). +- `AddressSpaceApplier.cs`: apply both realms; surgical updates (the F10b in-place path) + realm-aware; UNS display-name override changes are surgical (no rebuild), raw renames + follow WP1's remove+add plan. +- Tests: `DriverHostActorLiveValueTests`/`WriteRoutingTests`/`NativeAlarmTests` extended + with dual-NodeId cases; a fan-out drift test (publish once → both node states carry + identical value/quality/timestamp). + +### Wave C — alarms + verification hardening (2 agents) + +**B4-WP4 — multi-notifier alarms** + +- Materialize each native alarm ONCE at the raw tag (parent = its device/group folder, + promoted via `EnsureFolderIsEventNotifier` — existing method, line ~1286). For each + referencing equipment's UNS folder, wire the design's normative pattern: + `alarm.AddNotifier(ctx, null, isInverse: true, equipFolder)` + + `equipFolder.AddNotifier(ctx, null, isInverse: false, alarm)` + + `EnsureFolderIsEventNotifier(equipFolder)`. One `ReportEvent` fans to all roots; + **never duplicate ReportEvent per root** (distinct EventIds break Server-object dedup + + Part 9 ack correlation — design decision, not implementer discretion). +- **Teardown symmetry (implementation obligation):** track wired notifier pairs like + `_notifierFolders` and call `RemoveNotifier(..., bidirectional: true)` on + rebuild/subtree-removal/reference-removal — inverse-notifier entries otherwise leak + across redeploys. +- Part 9 ack/confirm/shelve keep routing on **ConditionId = RawPath** (never + SourceNodeId); the `AlarmAck` role gate and the `alarm-commands` DPS flow are + unchanged. +- `Commons/Messages/Alerts/AlarmTransitionEvent.cs` gains the (possibly empty) list of + referencing equipment paths; `/alerts` (`AlertHub` + `Alerts.razor`) shows one row per + condition (primary identity RawPath + condition NodeId) with the equipment list as + display metadata. +- `NativeAlarmProjector` + scripted-alarm surfaces untouched except NodeId scheme sweeps. + +**B4-WP5 — integration tests + docs** + +- 2-node harness: dual-namespace materialization, UNS-write-reaches-driver, alarm event + at both notifiers, HistoryRead via both NodeIds (NullHistorianDataSource `GoodNoData` + path suffices offline), redundancy: ServiceLevel + alarm-emit gate unaffected by the + second namespace (the redundancy-state delivery bugs were rig-only — verify on the + 2-node rig, not just unit tests). +- Docs in the same PR: `CLAUDE.md` (address-space + tag-concept sections), `docs/Uns.md`, + `docs/ScriptEditor.md`, `docs/Historian.md`, `docs/ScriptedAlarms.md`, + `docs/AlarmTracking.md`; the ScadaBridge cutover note + umbrella-index update + (`../scadaproj/CLAUDE.md`) per the design's cross-repo plan. + +## Wave schedule + +| Wave | Packages | Agents | Notes | +|---|---|---|---| +| — | Contracts commit (URIs, `V3NodeIds`, `AddressSpaceRealm`) | coordinator | Before any fan-out | +| A | WP1 ∥ WP2 | 2 | Composer/planner vs node-manager/sinks — disjoint files; both build against the contracts commit | +| B | WP3 | 1 | Runtime binding integrates A's output — single agent | +| C | WP4 ∥ WP5 | 2 | Alarms vs tests/docs — WP5 rebases last | + +## Batch verification gate — the v3.0 live gate (docker-dev + Client.CLI) + +Rebuild **both** centrals; fixtures up (`modbus standard` at minimum); deploy a config +with: a Modbus raw tag (historized), a Calculation tag reading it, an equipment +referencing both (one with a display-name override), a native alarm on the Modbus tag, +and a VirtualTag using `{{equip}}/`. + +1. **Browse both namespaces** (Client.CLI `browse -r -d 6`): Raw shows + Folder→Driver→Device→Group→Tag with `s=` NodeIds; UNS shows + Area/Line/Equipment with effective names; the UNS variable `Organizes`-references its + raw node. +2. **Single-source fan-out:** `subscribe` to the raw NodeId and the UNS NodeId + simultaneously → identical values/timestamps as the fixture changes; the calc tag + computes; break its script → Bad + script-log row; fix → recovery. +3. **Writes:** `write` via the UNS NodeId → device changes (read back via raw NodeId); + repeat via raw NodeId. Role gating: an LDAP user without `WriteOperate` is rejected + on both. Failed-write revert: enable the modbus `exception_injector` FC06 rule → + write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good + phantom). Remember Client.CLI `write` reads first — target a Good node. +4. **History:** `historyread` against the raw NodeId and the UNS NodeId returns the same + series under the same historian tagname (live gateway if VPN available, else + `GoodNoData` parity from the Null source); provisioning tally logs + `dispatched=N, failed=0`. +5. **Alarms:** trip the native alarm → `/alerts` shows ONE row carrying the equipment + path list; Client.CLI `alarms` subscribed at (a) the Server object — exactly **one** + event per transition (the shared-snapshot dedup check), (b) the raw device folder, + (c) the equipment folder — each receives the event; `ack` via the equipment-side + subscription works (ConditionId routing). Redeploy (rebuild path) then re-trip → + still exactly one Server-object copy (teardown-symmetry check: no leaked notifier + duplicates). +6. **Rename cascade:** rename the raw tag in `/raw` (accept the warnings) + deploy → + old raw NodeId gone, new present; UNS reference follows automatically; the absolute + RawPath script literal now fails as documented (`BadNodeIdUnknown` at evaluation); + the `{{equip}}` VirtualTag keeps working (reference-relative). +7. **Redundancy sanity** on the 2-node rig: ServiceLevel 240/100 split intact; alarm + rows single (no double-emit from the second namespace). + +All seven legs recorded with evidence in the PR. Merge = tag/announce **v3.0**; then the +cross-repo follow-ups (ScadaBridge re-bind coordination, umbrella index) per WP5. diff --git a/docs/plans/2026-07-15-v3-implementation-plan.md b/docs/plans/2026-07-15-v3-implementation-plan.md new file mode 100644 index 00000000..ac33fc17 --- /dev/null +++ b/docs/plans/2026-07-15-v3-implementation-plan.md @@ -0,0 +1,162 @@ +# v3 implementation plan — master orchestration + +**Date:** 2026-07-15 +**Design:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` (all open items resolved; required reading for every agent) +**Companion designs:** `2026-07-15-calculation-driver-mini-design.md` (Batch 2), `2026-07-15-universal-discovery-browser-design.md` (pre-v3 dependency) +**Batch plans:** `2026-07-15-v3-batch1-schema-identity-plan.md` · `2026-07-15-v3-batch2-raw-ui-calculation-plan.md` · `2026-07-15-v3-batch3-uns-rework-plan.md` · `2026-07-15-v3-batch4-address-space-plan.md` +**Executors:** Opus agents. This document is the coordinator's contract: batch order, parallel lanes, merge discipline, and the non-negotiable verification gates. + +## 1. Shape of the work + +Four **sequential batches** (each = one feature branch + one PR to master, merged only after +its live gate passes), plus one **batch-independent track (Track 0)** that can run in +parallel with Batch 1 because it touches nothing Batch 1 touches. + +``` +Track 0 (parallel with Batch 1): ContextMenu component · RFC-4180 CSV parser · + DriverType constants · Calculation evaluator core + │ +Batch 1 (schema + RawPath identity) ──► Batch 2 (/raw UI + Calculation driver) + ──► Batch 3 (UNS reference-only + {{equip}}) ──► Batch 4 (dual-namespace + address space) = v3.0 +``` + +**Precondition:** the universal `DiscoveryDriverBrowser` +(`2026-07-15-universal-discovery-browser-design.md`) is a **pre-v3 dependency that is NOT +in the tree yet** (verified 2026-07-15: no `DiscoveryDriverBrowser` exists; +`ITagDiscovery` has no `SupportsOnlineDiscovery` member — the browser design adds it). +Only Batch 2 work package **B2-WP6 (browse re-target)** is blocked on it. Everything else +proceeds; if the browser slips, B2-WP6 detaches into a follow-up PR and the Batch 2 gate +runs without the browse-commit leg. + +**Why the batches cannot overlap:** each batch's live gate deploys the previous batch's +schema/UI to docker-dev. Batch 2 authors against Batch 1's schema; Batch 3 references +Batch 2's raw tags; Batch 4 lights up what 1–3 authored. Parallelism lives *inside* +batches (see each batch plan's wave schedule), not between them. + +## 2. Agent execution model + +- **One coordinator agent per batch.** It reads the design + the batch plan, spawns + implementation agents per work package, owns the batch feature branch, and is the only + agent that merges. +- **Implementation agents run in isolated git worktrees** (`isolation: worktree` / + `EnterWorktree`). Hard rule from project history: parallel implementers on a shared + checkout race the git index and corrupt each other's staging — never share a tree. +- **Contract-first fan-out.** When a wave shares new types (e.g. the `RawPaths` helper, + the reshaped entities), the coordinator lands a small **contracts commit** on the batch + branch first; wave agents branch from it. No agent invents a shared type mid-wave. +- **File-ownership discipline.** The wave tables in each batch plan assign files to + exactly one work package per wave. If an agent finds it must touch a file owned by + another package, it stops and reports — the coordinator re-slices rather than letting + two agents edit one file. +- **Merge order** is the wave order. After each wave merges, the coordinator runs + `dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test ZB.MOM.WW.OtOpcUa.slnx` before + starting the next wave. A red build blocks the next wave; the coordinator fixes or + reverts, never "fixes forward" into a parallel wave. +- **Review checkpoint per wave:** a code-reviewer agent reviews the wave diff against the + batch plan before merge. Findings are fixed in the worktree, not post-merge. +- **Each batch ends with the live `/run` gate executed by an agent with browser tools** + (the docker-dev AdminUI has `Security__Auth__DisableLogin: "true"` — no sign-in needed; + drive `http://localhost:9200` directly). **Live gates are non-negotiable**: this project + has repeatedly shipped prod-inert code that 250+ unit tests and three reviews missed + (the `DeferredAddressSpaceSink` forwarding trap, the dormant `GatewayTagProvisioner`). + A batch is not done until its live-gate checklist passes. + +## 3. Track 0 — batch-independent work (start immediately, parallel with Batch 1) + +Four packages with zero overlap with Batch 1's files. Each is a separate worktree + +separate small PR (or merged onto the Batch 2 branch when it opens). + +| ID | Package | New files (nothing existing is touched) | Verification | +|---|---|---|---| +| T0-1 | **`ContextMenu` Blazor component** — reusable `oncontextmenu` menu with keyboard/touch fallback (explicit "⋯" affordance). No right-click menu exists anywhere in the Blazor app today (the only `ContextMenu` is in the Avalonia desktop client — do not confuse them). | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor` (+ `.razor.css`), `ContextMenuItem.cs` | Demo host page under a dev-only route; manual live check on docker-dev (AdminUI has **no bUnit** — Razor bugs must be live-verified) | +| T0-2 | **RFC-4180 CSV parser** — quoted fields, embedded commas/quotes/newlines, CRLF/LF; plus a writer that quotes on demand. Pure, no I/O. No CSV code exists in the repo today (verified) — greenfield. | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs`, `CsvWriter.cs`; tests in `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/` | Unit suite incl. RFC 4180 edge corpus (quoted quote, trailing newline, empty field vs absent field) | +| T0-3 | **`DriverTypeNames` constants class** — single source of truth for driver-type strings, authority = the factories' `DriverTypeName` (`TwinCAT`, `FOCAS`, `GalaxyMxGateway`). Consumed later by the four drifted maps (`DriverEditRouter._componentMap` currently keys `TwinCat`/`Focas`; `TagConfigEditorMap`; `TagConfigValidator`; `EquipmentTagConfigInspector` in **ControlPlane**, not AdminUI). T0-3 only *adds* the constants + a reflection test asserting they match every registered factory; the four consumers are rewired in Batch 2 (B2-WP8). | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`; test in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (or Commons.Tests if no such project) | Reflection test: every `DriverTypeName` exposed by `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` registrations has a matching constant, and vice versa | +| T0-4 | **Calculation evaluator core** — `CalculationEvaluator` built like `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `CompiledScriptCache` + `TimedScriptEvaluator` (2 s) + `VirtualTagContext`, single-tag mode (`ctx.SetVirtualTag` dropped + logged). Pure engine + unit tests only; the driver shell that hosts it is Batch 2 (B2-WP7). See mini-design §4. | New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` (evaluator + tests only at this stage) | Parity unit tests vs the VT evaluator: passthrough fast-path, timeout, sandbox violation, SetVirtualTag drop | + +## 4. Batch summaries + gates (detail in the batch plans) + +| Batch | Branch | Content | Gate (summary — full recipe in the batch plan) | +|---|---|---|---| +| 1 | `v3/batch1-schema-identity` | Greenfield EF schema (RawFolder/Device-universal/TagGroup/Tag-raw-only/UnsTagReference; Namespace + EquipmentImportBatch retired); `RawPaths` identity helper; `TagConfigIntent` sheds FullName; all 8 drivers' resolvers keyed by RawPath (blob-fallback parsers retired); endpoint→`DeviceConfig` move; composer/artifact/DraftValidator/UnsTreeService rewired; seeds + golden corpus rewritten. **Address space intentionally dark.** | Full solution build + test green (incl. rewritten corpus); `V3Initial` migration applies to a fresh DB; docker-dev re-seeds and both nodes boot with drivers Connected (values not yet exposed — that's Batch 4) | +| 2 | `v3/batch2-raw-ui` | `/raw` project tree (lazy loading + T0-1 menus), driver config/device modals (typed pages refactored to embeddable bodies), tag manual entry + CSV import/export (T0-2 + decided column dictionaries), browse re-target (gated on universal browser), `Calculation` driver (T0-4 + mini-design), `DriverTypeNames` rewire | Live `/run` on `:9200`: author folder→driver→device→tags in-tree; Test-connect green vs a fixture; CSV round-trip; browse-commit raw tags (if browser landed); author + deploy a calc tag. Proves authoring + probe only — no live values until Batch 4 | +| 3 | `v3/batch3-uns-rework` | Equipment Tags tab → reference list + cluster-scoped multi-select raw picker; effective-name uniqueness (authoring + DraftValidator); `{{equip}}` reference-relative resolution + unresolved-ref deploy error; rename-warning substring scan | Live `/run`: reference raw tags into an equipment; display-name override shows; deploy gate rejects a reference collision and an unresolved `{{equip}}` ref | +| 4 | `v3/batch4-address-space` | Dual namespaces, RawPath NodeIds, UNS fan-out registration, raw-only runtime binding, write routing via either NodeId, alarm multi-notifier (`AddNotifier` pattern + teardown symmetry), historian dual-registration, sink namespace discriminator + `DeferredAddressSpaceSink` forwarding + reflection-test extension | Live `/run` + Client.CLI: browse both namespaces; read/subscribe/write through both NodeIds; HistoryRead via both; native alarm visible at raw + equipment notifiers with exactly one Server-object copy; `{{equip}}` script computes; failed-write revert still works. **= v3.0** | + +## 5. Global conventions (binding for every agent) + +- .NET 10, C#, xUnit + **Shouldly**, Serilog. Match surrounding code style; comments only + for constraints the code can't show. +- JSON config keys are **camelCase**; enum values serialize as **string names** + (`JsonStringEnumConverter`). Systemic past bug: AdminUI pages serialized enums + numerically while DTOs were string-typed → authored configs faulted drivers. Every new + serialization site gets the converter, and every new editor gets a round-trip test. +- Editors/models **preserve unknown JSON keys** (the established `TagConfigModel` + `FromJson`/`ToJson` contract). .NET 10 `JsonNode.ToJsonString` gotchas are documented in + the existing models — copy the Modbus template. +- **Do not enable** `CentralPackageTransitivePinningEnabled` (breaks the Roslyn build via + the 5.0.0/4.12.0 split). Transitive CVE fixes = surgical direct `PackageReference` at a + common-ancestor project. +- EF migrations via `dotnet ef` with `DesignTimeDbContextFactory` (reads + `OTOPCUA_CONFIG_CONNECTION`, defaults to `10.100.0.35,14330`). All model config lives + inline in `OtOpcUaConfigDbContext.OnModelCreating` — keep it there (no + `IEntityTypeConfiguration` files exist; don't introduce the pattern mid-stream). +- Blazor: string component parameters need the `@` prefix when passing C# expressions + (the F15 gotcha that bit Global UNS); no bUnit exists — **live-verify every Razor + change** on docker-dev. +- Name collision: Runtime already owns + `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs` — any + browser-side capturing builder must use a different name or namespace-qualify. + +## 6. Global gotchas (project scar tissue — read before coding) + +1. **The forwarding trap (highest-severity recurring bug):** any new or changed method on + `IOpcUaAddressSpaceSink` / `ISurgicalAddressSpaceSink` + (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/`) **must** be forwarded through + `DeferredAddressSpaceSink` and covered by + `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs`. + A non-forwarded method compiles, passes unit tests, and is silently dead on driver + hosts in production. Same family: registering a service in DI without passing it into + its consumer (the dormant-`GatewayTagProvisioner` bug) — every "register X" step needs + a "consume X" assertion. +2. **`OnWriteValue` runs under the node-manager lock** — treat driver write dispatch as + fire-and-forget (`Tell`, never blocking ask) exactly as `OnEquipmentTagWrite` does + today (`OtOpcUaNodeManager.cs:1451`). +3. **Resolve `DriverHostActor` lazily** from consumers wired at startup — the actor + spawns after DI graph construction. +4. **docker-dev `:9200` round-robins central-1/central-2** behind Traefik — rebuild and + restart **both** (`docker compose build central-1 central-2 && docker compose up -d`) + before trusting any AdminUI verification; kind-changing config edits need + deploy-THEN-recreate. +5. Historian provisioning: `StorageRateMs` must be **> 0** (proto default 0 throws + gateway-side and silently fails all provisioning — PR #439). +6. Client.CLI `write` **reads first** — you cannot write a node whose read is Bad. To + force a protocol write failure on the rig, use the modbus fixture's + `exception_injector` (FC06 reject rule on a seeded Good address). +7. Data-plane role tests need `Security:Ldap:GroupToRole` in appsettings; LDAP is the + shared GLAuth `10.100.0.35:3893` (all test users password `password`). +8. Deploy API: `POST http://localhost:9200/api/deployments` with `X-Api-Key` from + `Security:DeployApiKey` (self-disables 503 when unset). Direct SQL edits against the + config DB need `SET QUOTED_IDENTIFIER ON`. +9. Driver fixtures live on the Docker host `10.100.0.35`, controlled via + `lmxopcua-fix ls|up|down|sync` from this VM (`docker -H ssh://` does NOT work). +10. Native-alarm routing keys on **`ConditionId`** (the authored ref), never + `SourceNodeId` — under v3 the ConditionId becomes the RawPath. + +## 7. Deliverable checklist (coordinator ticks per batch) + +- [ ] Batch branch opened from up-to-date master; contracts commit landed before fan-out. +- [ ] Every wave: worktree-isolated agents, file-ownership respected, reviewer pass, green + build+test after merge. +- [ ] Batch-specific unit/integration suites listed in the batch plan all green, including + the DB-backed suites (`Configuration.Tests` schema-compliance against + `10.100.0.35,14330`) and the 2-node harness + (`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`, default in-memory mode). +- [ ] Live `/run` gate checklist from the batch plan executed and evidence recorded in the + PR description (what was clicked/deployed/observed). +- [ ] Docs touched by the batch updated in the same PR (`docs/Configuration.md`, + `docs/Uns.md`, `docs/ScriptEditor.md`, `docs/Historian.md`, `CLAUDE.md` sections + that describe replaced behavior). +- [ ] Cross-repo: after Batch 4 merges, update the ScadaBridge coordination note + the + umbrella index `../scadaproj/CLAUDE.md` OtOpcUa entry (the design's cutover plan + step 4).