From 97e1f55bbbf2ff813b90a3e1e313204a849e4569 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 02:28:10 -0400 Subject: [PATCH] =?UTF-8?q?Draft=20ADR-001=20=E2=80=94=20Equipment=20node?= =?UTF-8?q?=20walker:=20how=20driver=20tags=20bind=20to=20the=20UNS=20addr?= =?UTF-8?q?ess=20space.=20Frames=20the=20decision=20blocking=20task=20#195?= =?UTF-8?q?=20(IdentificationFolderBuilder=20wire-in):=20the=20Equipment-n?= =?UTF-8?q?amespace=20browse=20tree=20requires=20a=20Config-DB-driven=20wa?= =?UTF-8?q?lker=20that=20traverses=20UNS=20=E2=86=92=20Equipment=20?= =?UTF-8?q?=E2=86=92=20Tag=20+=20hangs=20Identification=20sub-folders=20+?= =?UTF-8?q?=20identifier=20properties,=20and=20the=20open=20question=20is?= =?UTF-8?q?=20how=20driver-discovered=20tags=20bind=20to=20the=20UNS=20Equ?= =?UTF-8?q?ipment=20nodes=20the=20walker=20materializes.=20Context=20secti?= =?UTF-8?q?on=20documents=20what=20already=20exists=20(IdentificationFolde?= =?UTF-8?q?rBuilder=20unused;=20NodeScopeResolver=20at=20Phase-1=20cluster?= =?UTF-8?q?-only=20stub;=20Equipment=20+=20UnsArea=20+=20UnsLine=20+=20Tag?= =?UTF-8?q?=20tables=20with=20decisions=20#110=20#116=20#117=20#120=20#121?= =?UTF-8?q?=20already=20landed=20as=20the=20data-model=20contract)=20vs=20?= =?UTF-8?q?what's=20missing=20(the=20walker=20itself=20+=20the=20ITagDisco?= =?UTF-8?q?very/Config-DB=20composition=20strategy).=20Four=20options=20la?= =?UTF-8?q?id=20out=20with=20trade-offs:=20Option=20A=20Config-primary=20(?= =?UTF-8?q?Tag=20rows=20are=20the=20sole=20source=20of=20truth;=20ITagDisc?= =?UTF-8?q?overy=20becomes=20enrichment;=20BadNotFound=20placeholder=20whe?= =?UTF-8?q?n=20driver=20can't=20address=20a=20declared=20tag);=20Option=20?= =?UTF-8?q?B=20Discovery-primary=20(driver=20output=20is=20authoritative;?= =?UTF-8?q?=20Config-DB=20Equipment=20rows=20select=20subsets);=20Option?= =?UTF-8?q?=20C=20Parallel=20namespaces=20(driver-native=20ns=20+=20UNS=20?= =?UTF-8?q?overlay=20ns=20cross-referencing=20via=20OPC=20UA=20Organizes);?= =?UTF-8?q?=20Option=20D=20Config-primary-with-discovery-assist=20(same=20?= =?UTF-8?q?as=20A=20at=20runtime,=20plus=20an=20Admin=20UI=20offline=20dis?= =?UTF-8?q?covery=20panel=20that=20lets=20operators=20one-click-import=20d?= =?UTF-8?q?iscovered=20tags=20into=20the=20draft).=20Recommendation:=20Opt?= =?UTF-8?q?ion=20A=20now,=20defer=20Option=20D=20to=20v2.1.=20Reasons:=20m?= =?UTF-8?q?atches=20decision=20#110's=20framing=20straight-through,=20iden?= =?UTF-8?q?tical=20composition=20across=20every=20Equipment-kind=20driver,?= =?UTF-8?q?=20Phase=206.4=20Admin=20UI=20already=20authors=20Tag=20rows,?= =?UTF-8?q?=20BadNotFound=20is=20a=20legible=20failure=20mode,=20and=20not?= =?UTF-8?q?hing=20in=20A=20blocks=20adding=20D=20later=20without=20changin?= =?UTF-8?q?g=20the=20walker=20contract.=20If=20the=20ADR=20is=20accepted,?= =?UTF-8?q?=20spawns=20two=20tasks:=20Task=20A=20builds=20EquipmentNodeWal?= =?UTF-8?q?ker=20in=20Core.OpcUa=20(cluster=20=E2=86=92=20namespace=20?= =?UTF-8?q?=E2=86=92=20area=20=E2=86=92=20line=20=E2=86=92=20equipment=20?= =?UTF-8?q?=E2=86=92=20tag=20traversal,=20IdentificationFolderBuilder=20pe?= =?UTF-8?q?r=20Equipment,=205=20identifier=20properties,=20BadNotFound=20p?= =?UTF-8?q?laceholders,=20integration=20tests);=20Task=20B=20extends=20Nod?= =?UTF-8?q?eScopeResolver=20to=20join=20against=20Config=20DB=20+=20popula?= =?UTF-8?q?te=20full=20NodeScope=20path=20(unblocks=20per-Equipment/per-Un?= =?UTF-8?q?sLine=20ACL=20granularity=20+=20closes=20task=20#195=20with=20t?= =?UTF-8?q?he=20ACL=20integration=20test=20from=20the=20builder's=20docstr?= =?UTF-8?q?ing=20cross-reference).=20Consequences-if-we-don't-decide=20sec?= =?UTF-8?q?tion=20captures=20the=20status=20quo:=20Identification=20metada?= =?UTF-8?q?ta=20ships=20in=20DB=20+=20Admin=20UI=20but=20never=20reaches?= =?UTF-8?q?=20the=20OPC=20UA=20endpoint,=20external=20consumers=20can't=20?= =?UTF-8?q?resolve=20equipment=20via=20OPC=20UA=20properties=20as=20decisi?= =?UTF-8?q?on=20#121=20promises,=20and=20NodeScopeResolver=20stays=20clust?= =?UTF-8?q?er-level=20so=20finer=20ACL=20grants=20are=20effectively=20clus?= =?UTF-8?q?ter-wide=20at=20dispatch=20(Phase=206.2=20rollout=20limitation,?= =?UTF-8?q?=20not=20correctness=20bug).=20Draft=20status=20=E2=80=94=20see?= =?UTF-8?q?king=20decision=20before=20spawning=20the=20two=20implementatio?= =?UTF-8?q?n=20tasks.=20If=20accepted=20I'll=20add=20the=20tasks=20+=20sta?= =?UTF-8?q?rt=20on=20Task=20A.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adr-001-equipment-node-walker.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/v2/implementation/adr-001-equipment-node-walker.md diff --git a/docs/v2/implementation/adr-001-equipment-node-walker.md b/docs/v2/implementation/adr-001-equipment-node-walker.md new file mode 100644 index 0000000..b66f1b7 --- /dev/null +++ b/docs/v2/implementation/adr-001-equipment-node-walker.md @@ -0,0 +1,248 @@ +# ADR-001 — Equipment node walker: how driver tags bind to the UNS address space + +**Status:** Draft — seeking decision + +**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.