Full IntelliSense parity with scadabridge (completions, hover, signature
help, live diagnostics, formatting, inlay hints, global tag-path
completion), re-seated on OtOpcUa's real script compile context
(ScriptSandbox + VirtualTagContext wrapper + ForbiddenTypeAnalyzer +
DependencyExtractor). Reusable MonacoEditor.razor wired into the
ScriptEdit page and the virtual-tag modal; Monaco vendored locally.
10-task plan: (1) surface DriverType to the TagModal driver dropdown,
(2) shared TagConfigJson util + empty TagConfigEditorMap + DynamicComponent
dispatch scaffold, (3) Modbus editor as the worked example, (4-8) S7/AbCip/
AbLegacy/TwinCAT/Focas editors (parallelizable, disjoint files), (9) register
the five in the map, (10) docker-dev live verify (needs a non-Galaxy driver in
the rig). Each editor = pure FromJson/ToJson/Validate model (unit-tested) + thin
razor shell; preserves unknown JSON keys; driver pages untouched. Co-located
.tasks.json for resume.
Approved design: 6 new per-driver TagConfig editor components
(Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas) dispatched by the selected driver's
DriverType via a TagConfigEditorMap + DynamicComponent; the 3 unmapped drivers
keep the generic raw-JSON editor; no raw-JSON toggle on typed drivers. Editors
reuse the drivers' enums + JSON property names (not razor markup); driver pages
untouched. Pure FromJson/ToJson/Validate helpers are unit-tested (no bUnit);
live verify needs a non-Galaxy driver added to docker-dev. AdminUI-only, no
data-model change.
- New docs/Uns.md: the global UNS tree (Enterprise/Site read-only groupings,
editable Area→Line→Equipment→Tag/VirtualTag), navigating/filter, the
create/edit/delete modals, served-by cluster = UnsArea.ClusterId, tags are
equipment-bound while Galaxy/SystemPlatform folder-path tags stay on the
driver page, CSV import, and "changes apply on next Deploy".
- README.md: index row under Operational.
- CLAUDE.md: Testing section now points at /uns + docs/Uns.md.
Central cluster (2 fused admin+driver nodes) hosts the only UI + deploy
singleton; site clusters (2 driver-only nodes each) join the central mesh
and are logically separated by ClusterId. Each node applies only its own
cluster's drivers + address space on a global deploy. Approved design;
next step is the implementation plan.
A thin gateway over the admin-operations cluster singleton so CI/scripts can trigger a
deployment without the Blazor button. Forwards to the same IAdminOperationsClient.
StartDeploymentAsync; mounted on admin-role nodes. Auth is a fixed-time X-Api-Key check
against Security:DeployApiKey (orthogonal to the cookie-only web auth); AllowAnonymous so the
auth fallback doesn't 401 it, self-disabling (503) until the key is set. Outcome->status:
202/200/409/422. Unit tests for the key check + outcome mapping; HTTP E2E (real auth + real
deploy via the 2-node harness). Documented in docs/security.md.
Entities -> Phase7Composer.Compose -> MaterialiseHierarchy + MaterialiseEquipmentTags ->
real OtOpcUaNodeManager, asserting the Area/Line/Equipment folders + the equipment-signal
Variable land in a live OPC UA address space (structure-only). Also covers compose-side
EquipmentTags extraction. The cluster-level deploy + network-browse E2E + scadaproj loader
need the docker-dev fixture (not runnable on this dev box) and are tracked as a follow-up.
Two bundle-review fixes + idempotency coverage:
- CRITICAL: the planner ignored EquipmentTags, so an incremental deploy changing only
equipment tags produced an empty plan and HandleRebuild short-circuited before
materialising them. Add TagId to EquipmentTagPlan + Added/Removed/ChangedEquipmentTags
to Phase7Plan (diffed by TagId, in IsEmpty, driving Apply's needsRebuild) — mirroring
the GalaxyTags treatment.
- IMPORTANT: equipment variable NodeId was the raw driver FullName, which collides across
identical machines (e.g. two PLCs both exposing register 40001) — the second variable
was silently dropped. NodeId is now folder-scoped (parent/Name); FullName stays on
EquipmentTagPlan for the later values-routing milestone.
- Task 4: SDK-backed idempotency test (double-apply -> single variable); restart-safety
confirmed (RestoreApplied reuses the same RebuildAddressSpace -> HandleRebuild path).
- Minor: align composer equipment-tag sort with the artifact decoder (coalesce FolderPath).
Equipment folder DisplayName was the colloquial MachineCode; the live rebuild (artifact
ReadEquipmentNode) + composer now use the UNS level-5 Name segment, matching Area/Line
folders + EquipmentNodeWalker. NodeId stays the logical EquipmentId so browse-path
resolution + ACLs are unaffected.
Add Phase7Applier.MaterialiseEquipmentTags — a sink-based pass (Task-0 decision A) that
ensures each EquipmentTagPlan's Variable (NodeId = FullName) under its existing equipment
folder, nesting any FolderPath as a sub-folder. Wire it into OpcUaPublishActor.HandleRebuild
after the Galaxy pass. Variables start BadWaitingForInitialData; never re-creates equipment
folders (decision #4).
Add EquipmentTagPlan + an init-only EquipmentTags member on Phase7CompositionResult
(mirror of GalaxyTags). Populate it compose-side (Tag.EquipmentId != null AND owning
namespace Kind == Equipment) and artifact-decode-side via BuildEquipmentTagPlans, with
FullName extracted from Tag.TagConfig. Init-only member (not a 7th positional param) so
existing convenience constructors + call sites are untouched.
Implements WS-1/WS-2/WS-4 (+ tests) of the materialization scope: carry
Equipment-namespace tags through the composition/artifact, add a sink-based
MaterialiseEquipmentTags pass to the live rebuild, browse folders by friendly
Name (NodeId stays the logical Id). 6 tasks with dependencies; structure-only
(BadWaitingForInitialData leaves) — live values are the next milestone. Resume
via /executing-plans docs/plans/2026-06-06-equipment-namespace-structure-milestone.md
Equipment-kind namespaces materialise only their Area/Line/Equipment folder
skeleton on deploy, not the signals under them: EquipmentNodeWalker is fully
built + unit-tested but has no production call site, the composition/artifact
drops EquipmentId-bound tags, and no value source is wired (OpcUaClient driver
factory missing; VirtualTag ITagUpstreamSource unregistered). Documents the
gaps, workstreams, value-path options, and a structure-only-first sequencing.
docker-dev un-stubbed → binds zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local)
via cn=serviceaccount; sign in multi-role/password (group→role seeded by
seed-clusters.sql). Per-VM C:\publish\glauth + base DNs dc=lmxopcua/dc=otopcua
obsolete. Source of truth: scadaproj/infra/glauth/.
CODE-REALITY:
- Line 8: "Fifth (final)" → "Fifth of six"; TwinCAT is not the final CLI —
FOCAS (sixth) follows it. The "final" label was stale ordinal drift
from when there were fewer CLIs; 6 projects confirmed in
src/Drivers/Cli/.
- probe per-command flag table: `--type` row was missing the `-t`
shorthand. ProbeCommand.cs:25 declares
[CommandOption("type", 't', ...)] — same '-t' shorthand used by read,
write, subscribe; the probe table was the only one that omitted it.
Fixed to `-t` / `--type` for consistency.
STRUCTURAL: no rows in links-report.md for this doc.
STALE-STATUS: no state words found.
INLINE COMPLETENESS: no inventory-diff gaps for this doc.
CODE-REALITY:
- Line 7: "Fourth of four" → "Fourth of six"; there are 6 driver CLIs
(Modbus, AbCip, AbLegacy, S7, TwinCAT, FOCAS); confirmed by
src/Drivers/Cli/ project count.
- read section: removed the `DB10.STRING[0] -t String --string-length 80`
example that documented an unusable code path. String (and Int64,
UInt64, Float64, DateTime) live in S7DataType but are blocked in
S7Driver.UnimplementedDataTypes; any attempt returns BadNotSupported
(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs:327-333 and
:450). Added an explicit "not yet implemented" note with the
source location so readers know why those types are omitted.
STRUCTURAL: no rows in links-report.md for this doc.
STALE-STATUS: no state words found.
INLINE COMPLETENESS: no inventory-diff gaps for this doc.
CODE-REALITY: all four verbs (probe/read/write/subscribe), all common flags
(-g/--gateway, -P/--plc-type, --timeout-ms, --verbose), AssemblyName
otopcua-ablegacy-cli — all match code exactly. PCCC type table (Bit/Int/Long/
Float/AnalogInt/String/TimerElement/CounterElement/ControlElement) confirmed
against AbLegacy ReadCommand.cs:25 and WriteCommand.cs:24.
INLINE COMPLETENESS: corrected CLI roster count from "third of four" to
"third of six" to match DriverClis.md (S7, TwinCAT, FOCAS are also shipped).
Evidence: docs/Driver.AbLegacy.Cli.md:7 vs docs/DriverClis.md roster table.
STRUCTURAL: ../tests/.../Docker/README.md link confirmed present on disk.
check_links.py: 0 rows for this file.
CODE-REALITY fixes (file:line evidence):
- Read/Write tab write description was wrong: claimed the service reads
current value first to determine the target type before writing.
ReadWriteViewModel.WriteAsync (ReadWriteViewModel.cs:97-113) calls
WriteValueAsync directly with the raw string — no pre-read.
The type-inferring read-before-write lives only in the Subscriptions
tab write dialog (SubscriptionsViewModel.ValidateAndWriteAsync).
Button label is also "Write", not "Send" (ReadWriteView.axaml:35).
- Settings save timing was incomplete: MainWindowViewModel.DisconnectAsync
(MainWindowViewModel.cs:309) calls SaveSettings() on disconnect too;
doc said only "after successful connect and on window close".
STRUCTURAL: no rows in links-report.md for this file.
STALE-STATUS: no stale-status language found.
INLINE COMPLETENESS: no inventory gaps found.
CODE-REALITY: all four verbs (probe/read/write/subscribe), all common flags
(-h/--host, -p/--port, -U/--unit-id, --timeout-ms, --disable-reconnect,
--verbose), AssemblyName otopcua-modbus-cli — all match code exactly.
INLINE COMPLETENESS: corrected CLI roster count from "four" to "six" to
match DriverClis.md which lists all six shipped CLIs (Modbus, AB CIP,
AB Legacy, S7, TwinCAT, FOCAS); also added FOCAS to the explicit list.
Evidence: docs/Driver.Modbus.Cli.md:8 vs docs/DriverClis.md roster table.
STRUCTURAL: no link rows for this doc in links-report.md; v2/modbus-addressing.md
target confirmed present. check_links.py: 0 rows for this file.
CODE-REALITY: verified all 8 verbs + flags against src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/.
Verb set confirmed exact (connect/browse/read/write/subscribe/historyread/alarms/redundancy);
common options (-u/-U/-P/-S/-F/--verbose) match CommandBase.cs:32-64; per-command flags all match.
Fixes:
- Aggregate map: StandardDeviation maps to AggregateFunction_StandardDeviationPopulation,
not ...Sample (AggregateTypeMapper.cs:26). Doc table corrected.
- STALE: test count 52 -> 77 (77 [Fact] across tests/Client/...CLI.Tests, no Theory).
STRUCTURAL: links-report.md had no rows for docs/Client.CLI.md; check_links.py clean.
Executable name otopcua-cli is the CliFx SetExecutableName (Program.cs:12); csproj has no
AssemblyName, so dotnet-run invocation in CLAUDE.md is correct — no change.
STRUCTURAL (links-report.md):
- Repointed missing src/.../Security/Ldap/LdapAuthService.cs -> the real
OtOpcUaLdapAuthService.cs (Ldap/OtOpcUaLdapAuthService.cs implements
ILdapAuthService). Class was reorganized as a wrapper over shared
ZB.MOM.WW.Auth.Ldap. check_links now clean for docs/security.md.
CODE-REALITY — transport profiles (OpcUaApplicationHost.cs:15-23,59-64,374-409):
- Only THREE profiles exist: None, Basic256Sha256Sign,
Basic256Sha256SignAndEncrypt (NO hyphens, NO underscores). Removed the four
fabricated Aes128/Aes256 rows. Config binds by enum-member name; hyphenated
form does NOT bind. Documented this + the empty-list fallback to None.
- Config section is OpcUa (not OpcUaServer); key is the LIST
EnabledSecurityProfiles (not singular SecurityProfile). Program.cs:120 binds
'OpcUa'; Certificates.razor:80 reads OpcUa:PkiStoreRoot.
- No SecurityProfileResolver class exists — stated so explicitly.
CODE-REALITY — LDAP (LdapOptions.cs:21, OtOpcUaLdapAuthService.cs):
- Section is Security:Ldap (LdapOptions.SectionName), not OpcUaServer:Ldap.
- Authenticator is OtOpcUaLdapAuthService (wrapper) + LdapOpcUaUserAuthenticator
(IOpcUaUserAuthenticator.AuthenticateUserNameAsync), not bespoke
LdapUserAuthenticator/IUserAuthenticator.
- UseTls bool -> Transport enum (Ldaps/StartTls/None); AllowInsecureLdap ->
AllowInsecure. Added Enabled master switch + DevStubMode.
- Group->role mapping is downstream via IGroupRoleMapper<string>
(OtOpcUaGroupRoleMapper), NOT in the auth service. ILdapGroupsBearer and
DenyAllUserAuthenticator do not exist (fallback is NullOpcUaUserAuthenticator).
- GroupToRole values corrected to canonical roles (Viewer/Designer/
Administrator/Operator).
CODE-REALITY — ACL trie (TriePermissionEvaluator.cs, PermissionTrieCache.cs,
NodeScope.cs, NodePermissions.cs):
- NodePermissions backing type is int (not uint); lives in Configuration/Enums.
- Authorize(UserAuthorizationState, OpcUaOperation, NodeScope) returns
AuthorizationDecision.
- Evaluator is strictly fail-CLOSED. Removed the fabricated
'fail-open-during-transition' + Authorization:StrictMode key (no StrictMode
anywhere in source).
- Cache: generation-sealed Install/Invalidate/Prune. AclChangeNotifier does
NOT exist — removed.
- Added the SystemPlatform (Galaxy) scope hierarchy variant.
CODE-REALITY — control plane (AdminRole.cs, ServiceCollectionExtensions.cs:
113-131):
- AdminRole members are Viewer/Designer/Administrator (Task 1.7 rename from
ConfigViewer/ConfigEditor/FleetAdmin). DriverOperator/FleetAdmin are POLICY
names; DriverOperator requires roles Operator|Administrator.
CODE-REALITY — analyzer (UnwrappedCapabilityCallAnalyzer.cs:99-103,
AnalyzerReleases.Shipped.md):
- Confirmed category OtOpcUa.Resilience + severity Warning (already correct).
Corrected 'Five tests' (suite has 26 cases) and AlarmSurfaceInvoker
wrapper-home wording.
OTHER FIXES:
- v2 header: removed false AddJwtBearer/IPostConfigureOptions<JwtBearerOptions>
claim — auth is Cookie-only; JWT is mint-only via /auth/token for external
consumers (JwtTokenService.cs:25-48).
- Certificates.razor is a read-only viewer; removed fabricated
CertTrustService/CertTrustOptions promote claim.
- Audit: writer is AuditWriterActor (not AuditLogService); softened the
unverifiable server-side 'AUDIT:' Serilog-prefix claim.
STALE-STATUS / CODE-REALITY fixes:
- Table row ReleasedAt/ReleasedBy: "FleetAdmin" → "Administrator" (AdminRole
enum renamed in CanonicalizeAdminRoles migration). ReleasedBy now documents
that it is the LDAP operator name passed as explicit @ReleasedBy param — not
SUSER_SNAME() — per migration 20260522000001_AddReleasedByToReleaseExternalIdReservation.
- §4 Release: "FleetAdmin" → "Administrator"; added @ReleasedBy required param
requirement matching the updated stored-proc signature; replaced "SUSER_SNAME()"
attribution claim with the correct explicit-param description.
- §The Admin page: replaced entirely. Actual Reservations.razor uses bare
[Authorize] (not [Authorize(Policy="FleetAdmin")] and not "CanPublish").
The page is a read-only flat list (no Active/Released split, no Release row
action, no Release dialog). Redirected release-flow readers to
docs/v2/admin-ui.md §"Release an external-ID reservation".
Evidence:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor:2
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs:36
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:130
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260522000001_AddReleasedByToReleaseExternalIdReservation.cs
Structural (broken paths):
- Line 73: ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Contracts/
→ ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/
(contracts extracted to their own top-level project; no Contracts/ subfolder)
- Line 73: ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Pipe/
→ ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/
(directory renamed from Pipe/ to Ipc/)
Verified: both new targets exist on disk.
Code-reality (bitness):
- Line 10: historian sidecar platform "x86 (32-bit)" → "x64 (64-bit)"
Evidence: ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/
ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj
<PlatformTarget>x64</PlatformTarget> with explicit comment:
"x64 — AVEVA Historian 2020 ships an x64 build of aahClientManaged …
The earlier x86 default was inherited from v1's Galaxy.Host bitness
(MXAccess COM, retired in PR 7.2) and didn't reflect any constraint
of the Historian SDK itself."
Stale-status:
- Line 69: removed "Task 63 traefik docs — TODO"; link retargeted to
existing docs/v2/Architecture-v2.md (Traefik section present at line 114)
- Line 77: removed "v2 rewrite tracked as plan Task 62" — install script
ships complete at scripts/install/Install-Services.ps1