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.
13 KiB
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.DependencyRefs → VirtualTagActor._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)
- Scope: equipment-relative only. Not a general named/value parameter system. The single implicit "parameter" is the equipment base prefix.
- Token:
{{equip}}(double brace), reserved/fixed name. - Model: prefix token —
ctx.GetTag("{{equip}}.Source"), composer prepends the base. (Chosen over sibling-by-Name resolution.) - Base source: derived from child tags — common prefix-before-first-dot of the equipment's configured tag FullNames. No migration, no new column.
- 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").
- For each FullName, take the substring before the first
SubstituteEquipmentToken(string source, string equipBase) -> string- Replaces the literal token
{{equip}}only insidectx.GetTag(...)andctx.SetVirtualTag(...)path-argument string literals (so{{equip}}in a comment,Loggerstring, or other code is left untouched — keeps it strictly path-scoped). - Implemented with the same regex shape as the existing
GetTagRefRegex, extended to also matchSetVirtualTag. This single shared helper absorbs the two duplicatedGetTagRefRegexcopies that exist today inPhase7ComposerandDeploymentArtifact(collapse the dependency-ref extraction + the new substitution into one tested place). - Identity when
{{equip}}is absent (back-compat).
- Replaces the literal token
Note:
{{equip}}substitution is purely textual prefixing; it does not need Roslyn, which is why it lives inCommonsand notCore.Scripting.OpcUaServerdeliberately does not referenceCore.Scripting.
B. Compose seam 1 — Phase7Composer.Compose
(src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs, the VirtualTag
producer at ~lines 291–310):
- Build
Dictionary<string /*EquipmentId*/, string? /*base*/>from theequipmentTagsit already computes (group FullNames byEquipmentId→DeriveEquipmentBase). - Per VirtualTag:
var expanded = SubstituteEquipmentToken(src, baseByEquip.GetValueOrDefault(v.EquipmentId));(when base is null, leave the token in place — see Validation/degradation).Expression: expandedDependencyRefs: ExtractDependencyRefs(expanded)(now concrete).
C. Compose seam 2 — DeploymentArtifact.BuildEquipmentVirtualTagPlans
(src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs):
ParseCompositionalready computesequipmentTags = BuildEquipmentTagPlans(root)beforeBuildEquipmentVirtualTagPlans(root). Thread the derivedEquipmentId → basemap (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/GetTagRefRegexhere is replaced by the sharedCommonshelper 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 referencesConfiguration(DB) and the newCommonshelper. - This is the real gate. The composer degrades gracefully: an unresolved
{{equip}}leaves a literal that matches no FullName →BadNodeIdUnknownquality 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. (ReusesIScriptTagCatalog.)
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,Loggerstring, code): untouched bySubstituteEquipmentToken(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 inGetTagandSetVirtualTagliterals; leaves comments /Loggerstrings / 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 withPhase7Composerfor 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;ScriptAnalysishover +{{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.