- 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.
38 KiB
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:
- Raw — a Kepware-style, driver-centric tree that is the literal I/O map and the single source of truth for every value node.
- 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
/unspage (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) andns=UNS(equipment-path NodeIds). UNS nodesOrganizes →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 storedBaseDataVariableState.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): bespokeIDriverBrowserfirst (OpcUaClient, Galaxy), else the universalDiscoveryDriverBrowserwhen the driver'sITagDiscovery.SupportsOnlineDiscoveryis 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
Calculationpseudo-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.
- Signal-level calcs = raw tags bound to a
- 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,
TagConfigeditors, andIDriverProbes inside the new tree/popup shell.
Current state (baseline being replaced)
- A
ServerCluster(PKClusterId) owns a flat list ofDriverInstancerows — there is no folder/grouping construct for drivers today. - Drivers have
Devicerows (multi-device drivers only) andPollGrouprows. Caveat (review finding): theDeviceEF table is vestigial — no production code path writes it; the "multi-device" drivers (AbCip, AbLegacy, TwinCAT, FOCAS) embed aDevices[]array insideDriverConfigJSON, and single-endpoint drivers embedHost/EndpointUrlinDriverConfig. v3 materializes the table for real — see the Device entry below. Tagbinds to a driver viaDriverInstanceId+ schemalessTagConfigJSON and lives either under anEquipment(EquipmentId) or at a namespace-rootFolderPath.- Tag identity today is
FullName, which is derived, not stored:TagConfigIntent.Parsere-derives it fromTagConfigat every seam. Galaxy/OpcUaClient write an explicit"FullName"key (dotted/opaque ref); the six protocol drivers do not — their FullName is the entire rawTagConfigJSON blob. That one string is simultaneously the driver wire-ref (EquipmentTagRefResolverkey), the value fan-out key, the write-routing target, the historian default tagname + mux interest key, the native-alarmConditionId(both directions), and thectx.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}viaEquipmentNodeIds), 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 byDriverEditRouter._componentMap. DriverTypedispatch 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 throughEquipmentTagRefResolver/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.RawFolderIdPK,ClusterIdFK, nullableParentRawFolderId(null = root under cluster),Name,SortOrder.DriverInstance(reshaped) — gains nullableRawFolderId(null = cluster root). KeepsClusterId,DriverType,DriverConfig,ResilienceConfig. Drops per-driverNamespaceId. TheNamespaceentity,NamespaceKind, andNamespaceKindCompatibilityare retired outright — the two OPC UA namespaces are implicit (see Address space).NamespaceKind.Simulatedverified vestigial 2026-07-15: it is a reserved enum member, a "reserved" dropdown option inNamespaceEdit.razor, and aNamespaceKindCompatibilityflag documented "future Simulated namespace (replay driver — not in v2.0)" — no driver declares it, no seed populates it, and the composer filters onKind == Equipmentonly. Retired with the entity; a future replay/simulator driver is simply another RawDriverType, which the v3 model handles natively.Device(materialized for real, now universal) —DeviceIdPK,DriverInstanceIdFK,Name,DeviceConfigJSON. Every driver has ≥1 device; single-endpoint drivers (S7, TwinCAT, FOCAS, OpcUaClient, Galaxy, Calculation) get an auto-created default device. Endpoint/connection settings move fromDriverConfigintoDeviceConfigfor all drivers (the Kepware channel/device split:DriverConfigkeeps protocol/channel-level settings;DeviceConfigcarries host/endpoint + per-device settings). This retires the embeddedDevices[]arrays in the four multi-device drivers'DriverConfigand 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.TagGroupIdPK,DeviceIdFK, nullableParentTagGroupId,Name,SortOrder.Tag(reshaped → raw-only) —TagIdPK,DeviceIdFK (required), nullableTagGroupId,Name,DataType,AccessLevel,WriteIdempotent(retained — dropping it would regress the R2 resilience fix), nullablePollGroupId,TagConfigJSON. DropsEquipmentIdandFolderPath.PollGroup(unchanged) — driver-scoped polling groups.
UNS side
UnsArea → UnsLine → Equipment(kept) — butEquipmentloses itsDriverInstanceId/DeviceIdbinding.UnsTagReference(new) — the projection.EquipmentIdFK,TagIdFK (→ raw tag), nullableDisplayNameOverride,SortOrder.VirtualTag,ScriptedAlarm(unchanged) — per-equipment, UNS-native.- The orphaned
EquipmentImportBatch/EquipmentImportRowtables (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
(
DisplayNameOverrideelse the raw tag'sName) must be unique across references, VirtualTags, and ScriptedAlarms. Enforced at authoring (service-level check) and at deploy time inDraftValidator— 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
Tagis blocked while anyUnsTagReferencepoints at it; the UI names the referencing equipment. RawFolder/TagGroupdeletes are blocked when non-empty (matches today'sOnDelete(Restrict)convention).- The default
Deviceof 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
ContextMenuis in the Avalonia desktop client). A reusableoncontextmenucomponent 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/Loadingexist, butGlobalUnseager-loads the whole structure./rawwires lazy loading for the first time (per-device tag counts can be large).
Reuse (not rebuild):
- Configure driver hosts the existing typed driver forms (
ModbusDriverPageet al.) refactored into embeddable form bodies inside aDriverConfigModal. The shared inner sections already exist (DriverFormShell,DriverIdentitySection,DriverTestConnectButton,DriverResilienceSection); what moves out of each ~500-line page is the page-owned@pageroute,EditForm, ClusterNav header, DB load, and post-save navigation. - Test connect uses the existing
DriverTestConnectButton+IDriverProbepath (transient config, no persistence). - Edit tag uses the existing per-driver
TagConfigeditors viaTagConfigEditorMap.
Add tags ▸ (capability-based) on a Device or TagGroup:
- Manual entry — grid: name, datatype, access + the driver-typed
TagConfigeditor. - 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 nestedalarmobject rides flat dotted sub-columns (alarm.alarmType,alarm.severity,alarm.historizeToAveva); anything else non-flat rides theTagConfigJsonfallback column. Staged parse → review grid → commit (RFC 4180 parser). Export round-trips the same shape. (Review correction: the previously citedEquipmentImportBatchstaged 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 → universalDiscoveryDriverBrowserwhenSupportsOnlineDiscovery→ manual entry; seedocs/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
Tagrows under the target Device/TagGroup —Namefrom the leaf's browse name,DataTypefromDriverAttributeInfo, and the leaf's driver reference (attr.FullName) written into the driver-typedTagConfigas 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 "commitTagConfig.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'sconfigJson(the universal browser connects a real ephemeral driver instance; it needs the device endpoint).
- Commit contract: multi-selected leaves become raw
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
TagConfigeditor 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
ImportEquipmentModaldrops itsDriverInstanceIdcolumn (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'sUnsTagReferencerows 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 ares=<RawPath>. Folders/drivers/devices/tag-groups areObject/Foldernodes; tags areVariablenodes. Stable absent rename.ns=UNS— NodeIds are equipment paths:s=<Area>/<Line>/<Equipment>/<EffectiveName>. Area/Line/Equipment are folders; eachUnsTagReferenceis 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)
Calculationpseudo-driver (new DriverType) — signal-level calc tags are raw tags bound to this driver. Registered inDriverTypeRegistrylike any driver; a single defaultEnginedevice; the tag'sTagConfigholds a script reference (ScriptId) plus the trigger model mirrored from VirtualTags:changeTriggered(re-evaluate on any input change) and/ortimerIntervalMs(≥ 50), at least one required. Inputs arectx.GetTagRawPath 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 aDraftValidatorerror. No PollGroups; the driver'sIDriverProbeis 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 (
Calculationdriver); 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 newIDependencyConsumercapability interface feeding the driver from the existing per-node dependency mux, sharedScriptentity viaTagConfig.scriptId, change-trigger via mux + timer-trigger by reviving the dormantTimerTriggerSchedulerinside the driver, deploy gates (scriptId existence + Tarjan cycle check via the dormantDependencyGraph; 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,
DriverTypecanonicalization, RawPath resolution per driver, UNS-reference resolution,{{equip}}reference-relative substitution + unresolved-ref rejection, calc-tag cycle detection,Calculationdriver 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
DeferredAddressSpaceSinkforwarding trap). Every batch gets a docker-dev/rungate 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=RawNodeId (client subscriptions to the old NodeId break), the default historian tagname (history continues under a new tag unless ahistorianTagnameoverride pins it),ctx.GetTagliterals in scripts (deploy-gate error for{{equip}}refs; silentBadNodeIdUnknownfor 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 settinghistorianTagnamebefore renaming. This is Kepware-equivalent behavior, accepted deliberately. Script-literal detection for the rename warning (decided): a substring scan ofScriptbodies 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 throughUnsTagReferencerows, 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
DraftValidatordeploy 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— seedocs/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 —
Calculationpseudo-driver for signal-level calc tags (mini-design:docs/plans/2026-07-15-calculation-driver-mini-design.md)