Files
lmxopcua/docs/plans/2026-06-10-equipment-relative-tag-paths-design.md
T
Joseph Doherty 5044664379 docs(scripting): design for equipment-relative tag paths ({{equip}})
Approved brainstorm: a reserved {{equip}} token in ctx.GetTag/SetVirtualTag
path literals is substituted at the compose seams with the owning equipment's
tag base prefix (derived from child-tag FullNames). Lets one virtual-tag script
be reused across machines. No schema migration, runtime untouched.
2026-06-10 07:27:44 -04:00

13 KiB
Raw Blame History

Equipment-Relative Tag Paths ({{equip}}) — Design

Date: 2026-06-10 Status: Approved (brainstorming → ready for writing-plans) Author: brainstormed with Claude Code

Goal

Let one virtual-tag C# script be authored once and reused across every equipment instance, by giving scripts an equipment-relative way to reference sibling tags. Concretely: a reserved token {{equip}} inside a ctx.GetTag(...) / ctx.SetVirtualTag(...) path literal is replaced at deploy time with the owning equipment's tag base prefix.

// authored ONCE, bound to many VirtualTags (one per machine):
return System.Convert.ToInt32(ctx.GetTag("{{equip}}.Source").Value) > 50;
//  → "TestMachine_001.Source"  for the VirtualTag under equipment TestMachine_001
//  → "TestMachine_002.Source"  for the VirtualTag under equipment TestMachine_002

This kills the current pain: the rig has roughly one Script row per VirtualTag (~1036) because each hard-codes its own machine's absolute tag references. With {{equip}}, many VirtualTags can share a single template Script.

Background / why this shape

The literal-only constraint (the hard rule that shapes everything)

Virtual-tag input paths must be string literals. DependencyExtractor (src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs) rejects variables, concatenation, interpolation, and method-returned strings at publish, because the change-trigger dependency graph is built statically from the literals — the engine must know every upstream tag a script reads at load time to wire its subscriptions.

Therefore we cannot "pass a base-path string the script concatenates" (ctx.GetTag(base + ".Source") would be rejected). Instead, the substitution happens at the compose seam, before dependency extraction: the script text is rewritten per-VirtualTag so the path is a normal concrete literal by the time the extractor and the runtime see it. The literal-only invariant is preserved and the runtime is completely untouched.

What the runtime resolves against (verified)

ctx.GetTag("X") resolves against the driver FullName — the wire address stored in Tag.TagConfig (for Galaxy/MXAccess this is the dotted ref object.attribute, e.g. TestMachine_001.Source). The resolution chain: Phase7Composer.ExtractDependencyRefs harvests the ctx.GetTag("…") literals → EquipmentVirtualTagPlan.DependencyRefsVirtualTagActor._dependencyRefs → registered with DependencyMuxActor, whose _byRef map is keyed by AttributeValuePublished.FullReference = the FullName from Tag.TagConfig. The UNS-path engine (Core.VirtualTags.VirtualTagEngine, keyed by a slash-joined browse path) is dormant — not wired into the host — so UNS browse paths never resolve at runtime. (Source of truth: AdminUI/ScriptAnalysis/IScriptTagCatalog.cs remarks.)

So the per-machine varying part of a reference is the object prefix (TestMachine_001), and the base for an equipment is the substring before the first . of its tags' FullNames. The operator writes the separator and the relative tail ({{equip}}.Source), so the base value itself is just the prefix string TestMachine_001 (no trailing dot).

Why derive instead of store

The Equipment entity has no field that equals the Galaxy object prefix (Name is a lowercase UNS segment, MachineCode is "machine_001", etc.). The base only lives in the child tags' FullNames, which both compose seams already have. Deriving it means no schema migration and nothing new to serialize — the two seams independently derive the same base from data already present in both the DB snapshot and the deployment artifact.

Decisions (from brainstorming)

  1. Scope: equipment-relative only. Not a general named/value parameter system. The single implicit "parameter" is the equipment base prefix.
  2. Token: {{equip}} (double brace), reserved/fixed name.
  3. Model: prefix tokenctx.GetTag("{{equip}}.Source"), composer prepends the base. (Chosen over sibling-by-Name resolution.)
  4. Base source: derived from child tags — common prefix-before-first-dot of the equipment's configured tag FullNames. No migration, no new column.
  5. Editor polish included in v1{{equip}}-aware hover + {{equip}}. leaf completion (low effort).

Architecture

Components

A. Commons — pure helpers (no Roslyn). New file(s) under src/Core/ZB.MOM.WW.OtOpcUa.Commons/ (the assembly both compose seams reference):

  • DeriveEquipmentBase(IEnumerable<string> childFullNames) -> string?
    • For each FullName, take the substring before the first . (whole string if no dot).
    • Distinct prefixes: exactly 1 → that prefix; 0 (no tags) or >1 (divergent / multi-object) → null (caller treats as "no derivable base").
  • SubstituteEquipmentToken(string source, string equipBase) -> string
    • Replaces the literal token {{equip}} only inside ctx.GetTag(...) and ctx.SetVirtualTag(...) path-argument string literals (so {{equip}} in a comment, Logger string, or other code is left untouched — keeps it strictly path-scoped).
    • Implemented with the same regex shape as the existing GetTagRefRegex, extended to also match SetVirtualTag. This single shared helper absorbs the two duplicated GetTagRefRegex copies that exist today in Phase7Composer and DeploymentArtifact (collapse the dependency-ref extraction + the new substitution into one tested place).
    • Identity when {{equip}} is absent (back-compat).

Note: {{equip}} substitution is purely textual prefixing; it does not need Roslyn, which is why it lives in Commons and not Core.Scripting. OpcUaServer deliberately does not reference Core.Scripting.

B. Compose seam 1 — Phase7Composer.Compose (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs, the VirtualTag producer at ~lines 291310):

  • Build Dictionary<string /*EquipmentId*/, string? /*base*/> from the equipmentTags it already computes (group FullNames by EquipmentIdDeriveEquipmentBase).
  • Per VirtualTag:
    • var expanded = SubstituteEquipmentToken(src, baseByEquip.GetValueOrDefault(v.EquipmentId)); (when base is null, leave the token in place — see Validation/degradation).
    • Expression: expanded
    • DependencyRefs: ExtractDependencyRefs(expanded) (now concrete).

C. Compose seam 2 — DeploymentArtifact.BuildEquipmentVirtualTagPlans (src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs):

  • ParseComposition already computes equipmentTags = BuildEquipmentTagPlans(root) before BuildEquipmentVirtualTagPlans(root). Thread the derived EquipmentId → base map (or the equipment tags) into the vtag-plan builder and substitute identically to seam 1. No new artifact field — derivation uses the Tags already in the artifact.
  • The existing duplicated ExtractDependencyRefs/GetTagRefRegex here is replaced by the shared Commons helper so the two seams cannot drift.

D. AdminUI validation (src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/):

  • When a bound script's source contains {{equip}}, validate at VirtualTag save/publish that the VirtualTag's equipment yields a derivable base (≥1 configured tag under the equipment, single shared prefix). Surface a clear error ("equipment X has no single tag base; {{equip}} can't resolve — configure tags or split the equipment"). AdminUI references Configuration (DB) and the new Commons helper.
  • This is the real gate. The composer degrades gracefully: an unresolved {{equip}} leaves a literal that matches no FullName → BadNodeIdUnknown quality at runtime (observable in script logs), since the composer is a pure no-logging function.

E. Editor (Monaco / ScriptAnalysis) — light touch (src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs):

  • A literal containing {{equip}} is a valid C# string literal → diagnostics already don't flag it (no work needed to avoid false errors).
  • Hover: when the hovered tag-path literal contains {{equip}}, show "Equipment-relative path — {{equip}} is resolved to the owning equipment's tag base at deploy" instead of "not a known configured tag path".
  • Completion: after {{equip}}., offer the attribute leaf names seen across the catalog (the substring after the first dot of FullNames, e.g. Source) — equipment-relative completion that needs no specific equipment context. (Reuses IScriptTagCatalog.)

Data flow

Author (global Script):  ctx.GetTag("{{equip}}.Source")
        │
        ▼  (publish / deploy)
Compose seam (per VirtualTag, by EquipmentId):
    base = DeriveEquipmentBase(equipment's child-tag FullNames)   // "TestMachine_001"
    expanded = SubstituteEquipmentToken(src, base)                // ctx.GetTag("TestMachine_001.Source")
    DependencyRefs = ExtractDependencyRefs(expanded)              // ["TestMachine_001.Source"]
        │
        ▼  (unchanged from here on)
VirtualTagActor → DependencyMuxActor (keyed by FullName) → RoslynVirtualTagEvaluator
    ctx.GetTag("TestMachine_001.Source")  → readCache["TestMachine_001.Source"]  ✓

The two seams produce identical EquipmentVirtualTagPlans (the existing parity invariant between live-edit compose and artifact-decode is preserved).

Error handling / edge cases

  • No derivable base (equipment has no configured tags, or tags span multiple objects with divergent prefixes): AdminUI blocks save/publish with a clear message; composer leaves the token unsubstituted → runtime BadNodeIdUnknown.
  • FullName with no dot: prefix = the whole string; the single-prefix check still applies. ({{equip}} is meant for dotted Galaxy/MXAccess refs; non-dotted drivers simply won't have a meaningful relative tail.)
  • {{equip}} outside a tag-path literal (comment, Logger string, code): untouched by SubstituteEquipmentToken (scoped to GetTag/SetVirtualTag literals). Note {{ is not valid C# outside a string, so it can only appear in strings.
  • Back-compat: scripts without {{equip}} are unchanged (substitution is identity); existing ~1036 scripts keep working. No data migration.

Reuse migration (no schema change required)

Sharing a Script across VirtualTags already works (VirtualTag.ScriptId is just an FK; nothing enforces 1:1). Today they don't share only because the paths are hard-coded. With {{equip}} the operator: rewrites one template script with {{equip}}, re-points many VirtualTags' ScriptId at it, deletes the duplicate scripts — all ordinary AdminUI data editing.

Testing (no bUnit)

Unit (xUnit + Shouldly):

  • DeriveEquipmentBase: single prefix; divergent prefixes → null; empty → null; no-dot FullNames; mixed.
  • SubstituteEquipmentToken: replaces in GetTag and SetVirtualTag literals; leaves comments / Logger strings / code alone; multiple occurrences; identity when token absent; raw-string literals.
  • Phase7Composer: a VirtualTag with {{equip}} over an equipment's tags → Expression + DependencyRefs concrete; two equipments sharing one Script → different resolved refs.
  • DeploymentArtifact: encode tags + vtags → decode → substitute → concrete refs; parity with Phase7Composer for the same input.
  • AdminUI: save-validation when binding a {{equip}} script to an equipment with / without a derivable base.

Live (docker-dev /run, user drives — agent does not sign in): author a {{equip}} script, bind it to two machines' VirtualTags, deploy, confirm each resolves its own machine's tag and produces live values; confirm the editor hover + {{equip}}. completion.

Out of scope (YAGNI)

  • Arbitrary named/value parameters (ctx.Parameters[...]), typed accessors.
  • Explicit per-equipment base column / operator-set override.
  • Multi-object-equipment support (divergent prefixes intentionally error).
  • Sibling-by-Name resolution (resolve a relative leaf to a configured Tag's FullName) — a possible future enhancement if read targets aren't all configured as Tags.

Touched code (no Configuration entity / migration change)

  • New: Commons/…DeriveEquipmentBase + SubstituteEquipmentToken (+ tests in the Commons test project).
  • Modify: OpcUaServer/Phase7Composer.cs (substitute + derive; use shared helper).
  • Modify: Runtime/Drivers/DeploymentArtifact.cs (substitute + derive; use shared helper).
  • Modify: AdminUI — VirtualTag save/publish validation; ScriptAnalysis hover + {{equip}}. completion.
  • Docs: docs/ScriptEditor.md (+ a note in CLAUDE.md scripting section).

Hard rules carried from prior work: stage by path (never git add .), never stage sql_login.txt / pki/, no --no-verify, no force-push, no Configuration entity/migration change, agent does not sign in to the AdminUI.