Files
lmxopcua/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md
T
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- 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.
2026-07-15 16:50:30 -04:00

551 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# v3 — Kepware-style Raw tree + UNS projection (two-subtree address space)
**Date:** 2026-07-15
**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.
## Summary
Restructure how drivers and tags are managed so the OPC UA server exposes **two subtrees**:
1. **Raw** — a Kepware-style, driver-centric tree that is the literal I/O map and the
**single source of truth** for every value node.
2. **UNS** — a semantic re-organization (Area → Line → Equipment) that **references** raw
tags rather than authoring them.
This is a compatibility-breaking architectural change. It is **greenfield** — no production
config data is preserved; dev rigs are re-seeded against a fresh schema.
## Decisions (locked)
- **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 *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
Enterprise → Cluster → Area → …). Each *running* server still exposes only **its own**
cluster's two subtrees in its OPC UA address space.
- **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` (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** 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
(folders, CSV, historization, native alarms, UNS references) with no special-casing.
- *Equipment-context* calcs stay as **UNS VirtualTags** (unchanged) — `{{equip}}`
templating and per-equipment instantiation are inherently UNS concepts.
- Author rule: *inputs/meaning all device/signal-level → Raw; needs equipment context or
spans equipment → UNS VirtualTag.*
- **Migration:** greenfield / reset. Fresh schema; re-seed dev rigs. No data-migration code.
- **Build approach:** phased (Approach B) — clean schema, but **reuse** the existing typed
driver forms, `TagConfig` editors, and `IDriverProbe`s inside the new tree/popup shell.
## Current state (baseline being replaced)
- 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. **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. 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 ≥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:
`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`. It is simultaneously:
| Seam | Today's key | v3 key |
|---|---|---|
| `ns=Raw` NodeId | — (no raw tree) | `s=<RawPath>` |
| 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
- **`RawFolder`** *(new)* — nestable driver-organizing folder. `RawFolderId` PK, `ClusterId`
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`. 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 12.)*
- **`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`, **`WriteIdempotent`** *(retained — dropping
it would regress the R2 resilience fix)*, nullable `PollGroupId`, `TagConfig` JSON.
**Drops `EquipmentId` and `FolderPath`.**
- **`PollGroup`** *(unchanged)* — driver-scoped polling groups.
### UNS side
- **`UnsArea → UnsLine → Equipment`** *(kept)* — but `Equipment` **loses** its
`DriverInstanceId`/`DeviceId` binding.
- **`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
the referencing equipment.
- `RawFolder`/`TagGroup` deletes are **blocked when non-empty** (matches today's
`OnDelete(Restrict)` convention).
- The default `Device` of a single-endpoint driver cannot be deleted while it holds tags.
## AdminUI — the Raw project tree (`/raw`)
New global page **`/raw`** (peer to `/uns`), a lazy, cluster-rooted project tree mirroring
`GlobalUns` + `UnsTree`. Every node type has a **right-click context menu**; actions open
**modals** instead of navigating to a separate page:
- **Folder** — New folder / New driver / Rename / Delete.
- **Driver** — Configure / Test connect / New device / Enable-Disable / Rename / Delete.
- **Device** — Configure / New tag-group / Add tags ▸ / Delete.
- **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`. 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`.
**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,
<driver-typed columns…>, 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 —
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
The `/uns` tree is unchanged; the **Equipment Tags tab** becomes a reference list:
- Remove the driver dropdown and inline `TagConfig` editor from the equipment Tag flow.
- **"+ Add reference"** opens a picker into the Raw tree, **scoped to the same cluster** as
the equipment (cross-cluster references are structurally impossible), with **multi-select**
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 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}}/<RefName>")` 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
`<RefName>` 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 `s=<RawPath>`. 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>/<EffectiveName>`.
Area/Line/Equipment are folders; each `UnsTagReference` is a UNS variable node.
**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 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).
**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** (`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 + 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, 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 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."
## Error handling / edge cases
- **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).
- **Single-endpoint driver default device** — auto-created, uniform in the tree, undeletable
while holding tags.
- **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.
- 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 `<Driver>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
**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`)