Files
lmxopcua/docs/v2/implementation/adr-001-equipment-node-walker.md

13 KiB

ADR-001 — Equipment node walker: how driver tags bind to the UNS address space

Status: Accepted 2026-04-20 — Option A (Config-primary); Option D deferred to v2.1

Related tasks: #195 IdentificationFolderBuilder wire-in (blocked on this)

Related decisions in plan.md: #110 (Tag belongs to Equipment via FK in Equipment ns), #116 / #117 / #121 (five identifiers as properties, Equipment.Name as path segment), #120 (UNS hierarchy mandatory in Equipment ns; SystemPlatform ns exempt).

Context

Today the DriverNodeManager builds its address space by calling ITagDiscovery.DiscoverAsync on each registered driver. Every driver returns whatever browse shape its wire protocol produces — Galaxy returns gobjects with attributes, Modbus returns whatever tag configs the operator authored, AB CIP returns controller-walk output, etc. The result is a per-driver subtree, rooted under the driver's own namespace, with no UNS levels.

The Config DB meanwhile carries the authoritative UNS model for every Equipment-kind namespace:

ConfigGeneration
 └─ ServerCluster
    └─ Namespace (Kind=Equipment)
       └─ UnsArea
          └─ UnsLine
             └─ Equipment (carries 9 OPC 40010 Identification fields + 5 identifiers)
                └─ Tag (EquipmentId FK when Kind=Equipment; DriverInstanceId + FolderPath when Kind=SystemPlatform)

Decision #110 already binds Tag → Equipment by foreign key. Decision #120 requires the Equipment-namespace browse tree to conform to Enterprise/Site/Area/Line/Equipment/TagName. The building blocks exist:

  • IdentificationFolderBuilder.Build(equipmentBuilder, row) — pure function that hangs nine OPC 40010 properties under an Equipment node. Shipped, untested in integration.
  • Equipment table rows with UnsLineId FK + the 9 identification columns + the 5 identifier columns.
  • Tag table rows with nullable EquipmentId + a TagConfig JSON column carrying the wire-level address.
  • NodeScopeResolver — Phase-1 stub that returns a cluster-level scope only, with an explicit "future resolver will join against the Configuration DB" note.

What's missing is the walker: server-side code that reads the UNS + Equipment + Tag rows for the current published generation, traverses them in UNS order, materializes each level as an OPC UA folder, and wires IdentificationFolderBuilder.Build + the 5-identifier properties under each Equipment node.

The walker isn't pure bookkeeping — it has to decide how driver-discovered tags bind to UNS Equipment nodes. That's the decision this ADR resolves.

Open question

For an Equipment-kind driver, is the published OPC UA surface driven by (a) the Config DB's Tag rows, (b) the driver's ITagDiscovery.DiscoverAsync output, or (c) some combination?

SystemPlatform-kind drivers (Galaxy only, today) are unambiguous: decision #120 exempts them from UNS + they keep their v1 native hierarchy. The walker does not touch SystemPlatform namespaces beyond the existing driver-discovery path. This ADR only decides Equipment-kind composition.

Options

Option A — Config-primary

The Tag table is the sole source of truth for what gets published. ITagDiscovery becomes a validation + enrichment surface, not a discovery surface.

Walker flow:

  1. Read UnsArea / UnsLine / Equipment / Tag for the published generation.
  2. Walk Area → Line → Equipment, materializing each level as an OPC UA folder.
  3. Under each Equipment node:
    • Add the 5 identifier properties (EquipmentId, EquipmentUuid, MachineCode, ZTag, SAPID) as OPC UA properties per decision #121.
    • Call IdentificationFolderBuilder.Build to add the Identification sub-folder with the 9 OPC 40010 fields.
    • For each Tag row bound to this Equipment: ask the driver's IReadable / IWritable surface whether it can address Tag.TagConfig.address; if yes, create a variable node. If no, create a BadNotFound placeholder with a diagnostic so operators see the mismatch instead of a silent drop.
  4. ITagDiscovery.DiscoverAsync is re-purposed to enrich — driver may return schema hints (data type, bounds, description) that operators missed when authoring the Tag row. The Admin UI surfaces them as "driver suggests" hints for next-draft edits.

Trade-offs:

  • Matches decision #110's framing cleanly. Tag rows carry the contract; nothing gets published that's not explicitly authored.
  • Same model for every Equipment-kind driver. Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OpcUaClient all compose identically.
  • UNS hierarchy is always exactly as-authored. No race between "driver added a tag at runtime" and "operator hasn't approved it yet."
  • Aligns with the Config-DB-first operator story the Admin UI already tells.
  • Drivers with large native schemas (TwinCAT PLCs with thousands of symbols, AB CIP controllers with full @tags walkers) can't "just publish everything" — operators must author Tag rows. This is a pure workflow cost, not a correctness cost.
  • A Tag row whose driver can't address it produces a placeholder node at runtime (BadNotFound), not a publish-time validation failure. Mitigation: sp_ValidateDraft already validates per-driver references at publish — extend it to call each driver's existence check, or keep it as runtime-visible with an Admin UI indicator.

Option B — Discovery-primary

ITagDiscovery.DiscoverAsync is the source of truth for what gets published. The walker joins discovered tags against Config-DB Equipment rows to assemble the UNS tree.

Walker flow:

  1. Driver runs ITagDiscovery.DiscoverAsync — returns its native tag graph.
  2. Walker reads Equipment + Tag rows; uses Tag.TagConfig.address to match against discovered references.
  3. For each match: materialize the UNS path + attach the discovered variable under the bound Equipment node.
  4. Discovered tags with no matching Tag row: silently dropped (or surfaced under a Unmapped/ diagnostic folder).
  5. Tag rows with no discovered match: hidden (or surfaced as BadNotFound placeholder same as Option A).

Trade-offs:

  • Lets drivers with rich discovery (TwinCAT SymbolLoaderFactory, AB CIP @tags) publish live controller state without operator-authored Tag rows for every symbol.
  • Driver-native metadata (real OPC UA data types, real bounds) is authoritative.
  • Conflicts with the Config-DB-first publish workflow. Operators publish a generation
    • discover a different set at runtime + the two don't necessarily match. Diff tooling becomes harder.
  • Galaxy's SystemPlatform-namespace path still uses Option-B-like discovery — so the codebase would host two compositions regardless. But adding a second discovery-primary composition for Equipment-kind would double the surface operators have to reason about.
  • Requires each driver to emit tag identifiers that stably match Tag.TagConfig.address shape across re-discovery. Works for Galaxy (attribute full refs are stable); harder for AB CIP where the @tags walker may return tags operators haven't declared.
  • Operator-visible symptom of "my tag didn't publish" splits between two places: the Tag row exists (Config DB) + the driver can't find it (runtime discovery). Option A surfaces the same gap as a single BadNotFound placeholder; B multiplies it.

Option C — Parallel namespaces

Driver tags are always published under a driver-native folder hierarchy (discovery-driven, same as today). A secondary UNS "view" namespace is overlaid, containing Equipment nodes with Identification sub-folders + Organizes references pointing at the driver-native tag nodes.

Walker flow:

  1. Driver's native discovery publishes ns=2;s={DriverInstanceId}/{...driver shape} as today.
  2. Walker reads UNS + Equipment + Tag rows.
  3. For each Equipment, creates a node under the UNS namespace (ns=3;s=UNS/Site/Area/Line/Equipment)
    • adds Identification properties + creates Organizes references from the Equipment node to the matching driver-native variable nodes.

Trade-offs:

  • Preserves the discovery-first driver shape — no change to what Modbus / S7 / AB CIP publish natively; those projects keep working identically.
  • UNS tree becomes an overlay that operators can opt into or out of. External consumers that want UNS addressing browse via the UNS namespace; consumers that want driver-native addressing keep using the driver namespace.
  • Doubles the OPC UA node count for every Equipment-kind tag (one node in driver ns, one reference in UNS ns). OPC UA clients handle it but it inflates browse-result sizes.
  • Contradicts decision #120: "Equipment namespace browse paths must conform to the canonical 5-level Unified Namespace structure." Option C makes the driver namespace browse path NOT conform; the UNS namespace is a second view. An external client that reads the Equipment namespace in driver-native shape doesn't see UNS at all.
  • Identification ACL semantics get complicated — the sub-folder lives in the UNS ns, but the tag data lives in the driver ns. Two different scope ids; two grants to author.

Option D — Config-primary with driver-discovery-assist

Same as Option A, but ITagDiscovery.DiscoverAsync is called during draft authoring (not at server runtime) to populate an Admin UI "discovered tags available" panel that operators can one-click-add to the draft Tag table. At publish time the Tag rows drive the server as in Option A — discovery runs only as an offline helper.

Trade-offs:

  • Keeps Option A's runtime semantics — Config DB is the sole publish-time truth.
  • Closes Option A's only real workflow weakness (authoring Tag rows for large controllers) by letting operators import discovered tags with a click.
  • Draws a clean line between author-time discovery (optional, offline) and publish-time resolution (strict, Config-DB-driven).
  • Adds work that isn't on the Phase 6.4 checklist — Admin UI needs a "pull discovered tags from this driver" flow, which means the Admin host needs to proxy a DiscoverAsync call through the Server process (or directly into the driver — more complex deployment topology). v2.1 work, not v2.

Recommendation

Pick Option A. Ship the walker as Config-primary immediately; defer Option D's Admin-UI discovery-assist to v2.1 once the walker is proven.

Reasons:

  1. Decision #110 already points here. Tag.EquipmentId + Tag.TagConfig are the published contract. Option A is the straight-line implementation of that contract.
  2. Identical composition across seven drivers. Every Equipment-kind driver uses the same walker code path. New drivers (e.g. a future OPC UA Client gateway mode) plug in without touching the walker.
  3. Phase 6.4 Admin UI already authors Tag rows. CSV import, UnsTab drag/drop, draft diff — all operate on Tag rows. The walker being Tag-row-driven means the Admin UI and the server see the same surface.
  4. BadNotFound is a clean failure mode. An operator publishes a Tag row whose address the driver can't reach → client sees a BadNotFound variable with a diagnostic, operator fixes the Tag row + republishes. This is legible + easy to debug. Options B and C smear the failure across multiple namespaces.
  5. Option D is additive, not alternative. Nothing in A blocks adding D later; the walker contract stays the same, Admin UI just gets a discovery-assist panel.

The walker implementation lands under two tasks this ADR spawns (if accepted):

  • Task A — Build EquipmentNodeWalker in Core.OpcUa that drives the ClusterNode → Namespace → UnsArea → UnsLine → Equipment → Tag traversal, calls IdentificationFolderBuilder.Build per Equipment, materializes the 5 identifier properties, and creates variable nodes for each bound Tag row. Writes integration tests covering the happy path + BadNotFound placeholder.
  • Task B — Extend NodeScopeResolver to join against Config DB + populate the full NodeScope path (UnsAreaId / UnsLineId / EquipmentId / TagId). Unblocks the Phase 6.2 finer-grained ACL (per-Equipment, per-UnsLine grants). Add ACL integration test per task #195 — browse Equipment/Identification as unauthorized user, assert BadUserAccessDenied.

Task #195 closes on Task B's landing.

Consequences if we don't decide

  • Task #195 stays blocked. The IdentificationFolderBuilder exists but is dead code reachable only from its unit tests.
  • NodeScopeResolver stays at cluster-level scope. Per-Equipment / per-UnsLine ACL grants work at the Admin UI authoring layer + the data-plane evaluator, but the runtime scope resolution never populates anything below ClusterId + TagId — so finer grants are effectively cluster-wide at dispatch. Phase 6.2's rollout plan calls this out as a rollout limitation; it's not a correctness bug but it's a feature gap.
  • Equipment metadata (the 9 OPC 40010 fields, the 5 identifiers) ships in the Config DB
    • the Admin UI editor but never surfaces on the OPC UA endpoint. External consumers (ERP, SAP PM) can't resolve equipment via OPC UA properties as decision #121 promises.

Next step

Accept this ADR + spawn Task A + Task B.

If the recommendation is rejected, the alternative options (B / C / D) are ranked by implementation cost in the Trade-offs sections above. My strong preference is A + defer D.