Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.
- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
auto-covers the new signatures; added an explicit guard that every node-naming
sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
index via NamespaceIndexForRealm; all node maps (_variables/_folders/
_alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
reference node registers the SAME historian tagname as its backing raw node
(both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
index for default-realm nodes.
Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Un-darken the address-space composition for the v3 dual namespace.
Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
(raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
writable / historize+historian tagname / array shape). Native-alarm intent
attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
DataType/AccessLevel from the backing raw tag. Effective name =
DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
namespace at the call site). Additive/defaulted model changes only — the
un-migrated AddressSpaceApplier still compiles.
Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
rename = remove+add; UnsReferenceVariables keyed by the stable
UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.
Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).
Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely
additive so wave agents branch from a green base:
- AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that
travels alongside a node's s= identifier at every sink seam (WP2 adds it to
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it).
- V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw,
https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through
(s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff]
builders, both sharing RawPaths.Separator + ordinal segment validation.
Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired
OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without
depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node
manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri.
EquipmentNodeIds retirement is NOT in this commit: its callers are the applier +
runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned
(the composer does not use it) — deleting it here would red the base. WP3 sweeps
it in Wave B.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Uns.md Tags section rewritten to the reference-only model (UnsTagReference list,
cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md
gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
MEDIUM-1: the editor<->authoring<->deploy invariant now holds on the ScriptedAlarm
surface too. CreateScriptedAlarmAsync/UpdateScriptedAlarmAsync validate {{equip}}/<RefName>
in BOTH the predicate script and the message template (via ExtractEquipReferenceNamesFromText),
matching what DraftValidator already enforces at deploy. Refactored ValidateEquipTokenAsync
into shared LoadEquipReferenceNamesAsync + CheckEquipReferencesResolve helpers.
LOW-1: LoadEquipReferenceNamesAsync uses IsNullOrEmpty (defense-in-depth) to match
EquipmentReferenceMap.Build; empty override is already normalized->null at the write path.
LOW-3: parity test hardened with two edge cases — unresolved-ref-left-intact (both seams
leave {{equip}}/X identical) and folder+tag-group RawPath ancestry.
Tests: VirtualTagEquipTokenValidationTests 9/9 (+3 SA), parity 4/4 (+2). AdminUI 659/0.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Semantic collision git auto-merged: WP2 and WP4 each added a BuildReference
helper to the DraftValidatorTests partial class (same param types -> CS0111).
Kept one with the refId param name + default null, satisfying both call styles.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.
- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
mutations now call CheckAsync before persisting and surface a collision as the failure.
WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
dead decision-#122 driver-cluster guard.
Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now
raises three warning kinds over the affected (prefix-scanned) tags:
(a) refined — historized WITHOUT a historianTagname override (default tagname
is the moving RawPath, so history forks); a pinned (override) tag no longer
warns. Count/message adjusted.
(b) unchanged — UNS-referenced (names the equipment).
(c) NEW — substring-scan every Script body for the affected tag's OLD RawPath
(ordinal, false-positives tolerated by design; no AST). OLD RawPaths are
recomputed from the pre-save in-DB topology via RawPathResolver.
CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so
the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs;
IRawTreeService + RawTree.razor untouched (warnings flow through the existing
RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync
(UnsMutationResult, no warning surface) so it is out of scope without widening
the interface.
Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn,
unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.
Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.
Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
A calc-tag add/remove/edit or a script edit changes the merged DriverConfig,
which DriverSpawnPlanner routes to an in-place ApplyDelta (not a respawn), i.e.
CalculationDriver.ReinitializeAsync. The old ReinitializeAsync ignored the new
config: the tag table + dependency-ref set were built once at construction and
never rebuilt, so after a redeploy added tags never computed, removed tags kept
publishing, and edited scripts served stale results (deploy reported success).
Part 1 (HIGH): extract RebuildTagTable(config) — shared by the ctor and reinit
so they cannot drift — and have ReinitializeAsync re-bind the new config
(ParseOptions) and rebuild _tagsByRawPath/_changeDependents/_dependencyRefs/
_stateByRawPath under _lock, preserving last-known state for surviving tags,
dropping removed tags, and starting new tags fresh. _dependencyRefs now reflects
the new authored tags. Corrected the now-false ReinitializeAsync comment.
Part 2 (MEDIUM ordering hazard): the host's ReRegister was Tell-ed synchronously
alongside the ApplyDelta Tell, so the adapter re-read DependencyRefs before the
async ReinitializeAsync rebuilt them (stale set, no self-correction). Now
DriverInstanceActor sends DeltaApplied(driverInstanceId) to DriverHostActor
AFTER ReinitializeAsync completes, and HandleDeltaApplied re-registers only that
driver's adapter — sequenced after the rebuild. Removed the inline loop.
Test: added a redeploy test to CalculationDependencyFlowTests exercising the
real DependencyMuxActor + adapter + driver — a new tag reading a NEW dep flows +
computes, a removed tag stops publishing, an edited script serves the new
result, and DependencyRefs re-registers the new set. Fails on pre-fix code
(DependencyRefs stays stale ["src/a"]), passes after the fix.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.
Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
by the /raw modals are untouched.
Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live
values) plus the host seam that feeds it:
- IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation.
- CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate +
dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission,
Good recovery; reuses the T0-4 CalculationEvaluator.
- DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver,
registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged
→ OnDependencyValue; re-registers on every apply, torn down with the driver.
- Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test
csproj references the driver so the bin-scan discovers the factory.
- DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's
delivered TagConfig so the host-blind driver can compile it.
- DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId
existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges).
- Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer,
read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a
Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal
against the merged Driver+Device config and commits selected browse leaves as
raw Tag rows under the target, via IRawTreeService.ImportTagsAsync.
- RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser →
universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled),
merged config from LoadMergedProbeConfigAsync, multi-select over
DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror,
commit via ImportTagsAsync.
- RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→
OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/
attributeRef), target-group + mirror path combine. Unit-tested (39 tests).
- DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path
callback), default off preserves single-select pickers.
- RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Reviewer H1 (v3 regression): the endpoint→DeviceConfig split ADDED the endpoint to DeviceConfig but
the driver forms still serialized it into DriverConfig at defaults. Single-device worked only by an
STJ case-insensitive last-key-wins accident; a 2nd device on a single-top-level-Host driver (Modbus/S7)
silently reverted to the driver-form default (host=127.0.0.1). Fix: Modbus/S7/OpcUaClient driver-form
GetConfigJson strips the endpoint keys (host/port/unitId; host/port/rack/slot; scalar endpointUrl) from
the serialized channel config, leaving DeviceConfig as the sole source (OpcUaClient keeps its endpointUrls
failover list, which is driver-level policy).
Enum-serialization verified CLEAN fleet-wide; ImportTagsAsync all-or-nothing verified; RawTree wiring sound.
Deferred (documented in PR): H2 pre-existing Modbus/S7 form TimeSpan vs factory *Ms-int DTO mismatch (authored
durations dropped — predates v3); M1 CSV export drops non-columned alarm sub-keys; M2 blank flag column
clobbers a base-JSON isHistorized/isArray; M3 OpcUaClient device/driver endpoint precedence; L1 unused
LoadMergedProbeConfigAsync; L5 refresh collapses sibling expansion.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new
small dialogs, plus post-mutation subtree refresh:
- New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline),
RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker,
incl. Calculation).
- Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device ->
DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export
-> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename +
Delete + Toggle -> IRawTreeService directly via the dialogs.
- Refresh: reload the container after create-under, the parent after item-level
ops (parent found by searching the loaded tree; robust, non-throwing).
- Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal
message surface. Browse device stays a placeholder (Wave C). Driver-level
'Test connect' menu item removed (test-connect now lives on the device modal).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Wave-0 reviewer findings (no Critical/High):
- T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too.
- T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught.
- T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox