249 lines
13 KiB
Markdown
249 lines
13 KiB
Markdown
# 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.
|