Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.
The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.
Four decisions worth keeping:
- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
but v3 has no Namespace entity — the snapshot was retired when the address
space went dual-namespace, and the identifier appears nowhere in production
outside NodeScope. So the level could only ever fail to match, which silently
made every sub-cluster grant unreachable while cluster grants kept working.
It is now a realm token derived from the scope's Kind, not carried on it, so
the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
at build time. Depth-derived provenance could not name a raw level at all,
since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
uniqueness is ordinal, so Speed and speed can both exist under one device and
a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
unresolvable rows at a direct child of the trie root, a position the walker
cannot reach, so the grant looked placed and could never match — and every
test using that mode proved nothing about production placement. scopePaths is
now required, and a row that does not resolve is dropped with a diagnostic.
A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.
Also closed here, all found by review rather than asked for:
- NodeAcl rows had no deploy-time validation of any kind and were not reachable
from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
cluster, because it filtered on scope kind with no ClusterId predicate while
ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
test has been red since the key-minting commit merged — I did not run
AdminUI.Tests before merging stage 4.
Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.
Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
OtOpcUa documentation
Two tiers of documentation live here:
- Current reference at the top level (
docs/*.md) — describes what's shipped today. Start here for operator + integrator reference. - Implementation history + design notes at
docs/v2/*.md— the authoritative plan + decision log the current reference is built from. Start here when you need the why behind an architectural choice, or when a top-level doc says "see plan.md § X".
The project was originally called LmxOpcUa (a single-driver Galaxy/MXAccess OPC UA server) and has since become OtOpcUa, a multi-driver OPC UA server platform. Any lingering LmxOpcUa-string in a path you see in docs is a deliberate residual (executable name lmxopcua-cli, client PKI folder {LocalAppData}/LmxOpcUaClient/) — fixing those requires migration shims + is tracked as follow-ups.
Platform overview
v2 (2026-05-26): the separate
OtOpcUa.Server+OtOpcUa.Adminservices fused into a single role-gatedOtOpcUa.Hostbinary, joined by an Akka.NET cluster. See v2 design for the architectural decision.
- Core owns shared abstractions (driver capability contracts, scripting, virtual tags, alarm historian).
- Drivers plug in via capability interfaces in
ZB.MOM.WW.OtOpcUa.Core.Abstractions:IDriver,IReadable,IWritable,ITagDiscovery,ISubscribable,IHostConnectivityProbe,IAlarmSource,IHistoryProvider,IPerCallHostResolver. Each driver opts into whichever it supports. - Host (
src/Server/ZB.MOM.WW.OtOpcUa.Host) is the single fused binary (.NET 10, AnyCPU).OTOPCUA_ROLESenv decides what to mount:admin(Blazor + control-plane singletons),driver(OPC UA endpoint + per-node actors), or both. See ServiceHosting.md. - Cluster + ControlPlane + Runtime + AdminUI + Security sit between Core and Host. The cluster glues per-node actors into one logical fleet; the control-plane singletons (deploy coordinator, audit writer, redundancy state) live on the admin role-leader. See Redundancy.md.
- The Galaxy driver still reaches MXAccess via gRPC to a separately-installed mxaccessgw sidecar (sibling repo).
Where to find what
Architecture + data-path reference
| Doc | Covers |
|---|---|
| OpcUaServer.md | Top-level server architecture — Core, driver dispatch, Config DB, generations |
| AddressSpace.md | GenericDriverNodeManager + ITagDiscovery + IAddressSpaceBuilder |
| ReadWriteOperations.md | OPC UA Read/Write → CapabilityInvoker → IReadable/IWritable |
| DriverLifecycle.md | Server-side driver lifecycle + infrastructure contracts (IDriverFactory, IDriverProbe, IDriverHealthPublisher, IDriverConfigEditor, IHistorianDataSource) + the Commons library |
| Subscriptions.md | Monitored items → ISubscribable + per-driver subscription refcount (v1 archive) |
| AlarmTracking.md | IAlarmSource + AlarmSurfaceInvoker + OPC UA alarm conditions — native Galaxy alarms end-to-end (live) |
| AlarmTracking.md | Original alarm-tracking write-up (v1 archive) |
| AlarmHistorian.md | Core.AlarmHistorian store-and-forward sink — LocalDbStoreAndForwardSink, the replicated alarm_sf_events buffer + Primary-only drain, IAlarmHistorianWriter, dead-letter/retry/eviction |
| DataTypeMapping.md | Per-driver DriverAttributeInfo → OPC UA variable types (v1 archive — live mapping is in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs) |
| IncrementalSync.md | Address-space rebuild on redeploy + sp_ComputeGenerationDiff |
| HistoricalDataAccess.md | IHistoryProvider as a per-driver optional capability (v1 archive) |
| VirtualTags.md | Core.Scripting + Core.VirtualTags — Roslyn script sandbox, engine, dispatch alongside driver tags |
| ScriptEditor.md | Monaco script editor — Roslyn-backed IntelliSense (completions, diagnostics, hover, tag-path) for C# virtual-tag scripts; AdminUI/ScriptAnalysis/ backend |
| ScriptedAlarms.md | Core.ScriptedAlarms — script-predicate IAlarmSource + Part 9 state machine |
One Core subsystem is shipped without a dedicated top-level doc; see the section in the linked doc:
| Project | See |
|---|---|
Analyzers (Roslyn OTOPCUA0001) |
security.md § OTOPCUA0001 Analyzer |
Drivers
| Doc | Covers |
|---|---|
| drivers/README.md | Index of the eight shipped drivers + capability matrix |
| drivers/Galaxy.md | Galaxy driver — in-process gRPC client to the mxaccessgw sidecar |
| v1/drivers/Galaxy-Repository.md | Galaxy-specific discovery via the ZB SQL database (v1 archive — the gateway owns this path now) |
For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics, see v2/driver-specs.md.
Operational
| Doc | Covers |
|---|---|
| Configuration.md | Live appsettings + environment-variable reference (current state) |
| Configuration.md | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — OTOPCUA_GALAXY_* env vars now live in mxaccessgw config) |
| Uns.md | The global /uns master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs |
| security.md | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
| Redundancy.md | RedundancyStateActor (cluster-scoped singleton), ServiceLevelCalculator, IRedundancyRoleView, per-cluster meshes, Prometheus metrics |
| Reservations.md | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
| ServiceHosting.md | Single fused OtOpcUa.Host binary install/uninstall with OTOPCUA_ROLES gating; the historian backend is the external HistorianGateway |
| StatusDashboard.md | Pointer — superseded by v2/admin-ui.md |
Client tooling
| Doc | Covers |
|---|---|
| Client.CLI.md | otopcua-cli — OPC UA command-line client |
| Client.UI.md | Avalonia desktop client |
| DriverClis.md | Driver test-client CLIs — index + shared commands |
| Driver.Modbus.Cli.md | otopcua-modbus-cli — Modbus-TCP |
| Driver.AbCip.Cli.md | otopcua-abcip-cli — ControlLogix / CompactLogix / Micro800 / GuardLogix |
| Driver.AbLegacy.Cli.md | otopcua-ablegacy-cli — SLC / MicroLogix / PLC-5 (PCCC) |
| Driver.S7.Cli.md | otopcua-s7-cli — Siemens S7-300 / S7-400 / S7-1200 / S7-1500 |
| Driver.TwinCAT.Cli.md | otopcua-twincat-cli — Beckhoff TwinCAT 2/3 ADS |
| Driver.FOCAS.Cli.md | otopcua-focas-cli — Fanuc FOCAS/2 CNC |
Requirements
| Doc | Covers |
|---|---|
| reqs/HighLevelReqs.md | HLRs — numbered system-level requirements |
| reqs/OpcUaServerReqs.md | OPC UA server-layer reqs |
| v1/reqs/ServiceHostReqs.md | Per-process hosting reqs (v1 archive — only OtOpcUa server hosting remains in scope post-PR-7.2) |
| reqs/ClientRequirements.md | Client CLI + UI reqs |
| v1/reqs/GalaxyRepositoryReqs.md | Galaxy-scoped repository reqs (v1 archive — owned by mxaccessgw today) |
| v1/reqs/MxAccessClientReqs.md | Galaxy-scoped MXAccess reqs (v1 archive — owned by mxaccessgw today) |
| reqs/StatusDashboardReqs.md | Pointer — superseded by Admin UI |
Implementation history (docs/v2/)
Design decisions + phase plans + execution notes. Load-bearing cross-references from the top-level docs:
- v2/plan.md — authoritative v2 vision doc + numbered decision log (referenced as "decision #N" elsewhere)
- v2/admin-ui.md — Admin UI spec
- v2/acl-design.md — data-plane ACL + permission-trie design (Phase 6.2)
- v2/config-db-schema.md — Config DB schema reference
- v2/driver-specs.md — per-driver addressing + quirks for every shipped protocol
- v2/dev-environment.md — dev-box bootstrap
- v2/test-data-sources.md — integration-test simulator matrix (includes the pinned libplctag
ab_serverversion for AB CIP tests) - v2/multi-host-dispatch.md — per-PLC circuit breakers (Phase 6.1 decision #144)
- v2/v2-release-readiness.md — release-readiness tracker
- v2/phase-7-status.md — Phase 7 reconciliation: what shipped vs. the plan, and the five remaining gaps
- v2/implementation/phase--.md — per-phase execution plans with exit-gate evidence
v1 archive
The v1 in-process MXAccess architecture (Galaxy.Host + Galaxy.Proxy + Galaxy.Shared, .NET 4.8 x86 COM, the OtOpcUaGalaxyHost Windows service) was retired in PR 7.2 (2026-04-30, commit ae7106d). Docs that described that shape are kept under v1/ as historical record — see v1/README.md for the index.