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

38 KiB
Raw Blame History

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). 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.)
  • 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 IDriverProbes 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.
  • 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}/driversDriverTypePicker → 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 TagGroups.
    • 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:

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 EventIds/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 TagPaths, 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)