v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> resolution #471
Reference in New Issue
Block a user
Delete Branch "v3/batch3-uns-rework"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
v3 Batch 3 — UNS reference-only Equipment +
{{equip}}/<RefName>reference-relative resolutionImplements
docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md(WP1–WP4), executed via the/v3-batch 3coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, acode-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on
docker-dev.
What landed
IEffectiveNameGuard(authoring-time effective-name uniqueness seam; WP2 implements,WP1 consumes).
UnsTagReferencelist (effective name / RawPath / inheritedDataType+AccessLevel / display-name override); new
AddReferenceModalreusingRawTreein opt-inPickerMode(multi-select tag leaves + device/tag-group "Select all tags below"), cluster-scoped(structural + server-enforced
tag.cluster == equipment.cluster);UnsTreeServicereference CRUD;consumes
IEffectiveNameGuardin all colliding mutations;ImportEquipmentModal/EquipmentInputdropped
DriverInstanceId; deleted the retired equipmentTagModal.EffectiveNameGuard(injectable, ordinal) + DI registration; verified the deploy-gateUnsEffectiveNameCollisionrule (already computes ref effective name as override-else-current-raw-name,so it catches rename-induced collisions; ordinal; names both sources + equipment).
RawTreeService.BuildRenameWarningsAsync: refined historized warning (only tags withouta
historianTagnameoverride) + UNS-referenced + new substring scan of everyScript.SourceCodefor affected tags' OLD RawPaths (recomputed pre-save via
RawPathResolver, ordinal).{{equip}}/<RefName>reference-relative resolution.EquipmentScriptPaths.DeriveEquipmentBasedeleted; slash joint replaces the dot joint; new shared
EquipmentReferenceMap(equipmentId → effectiveName → RawPath) built identically at both compose seams (AddressSpaceComposer+DeploymentArtifact, byte-parity). Unresolved-ref deploy error (EquipReferenceUnresolved) covering VTscripts, SA predicates, and SA message-template tokens; authoring guard (
ValidateEquipTokenAsync,the Wave-A M1 fix); Monaco
{{equip}}/completion of reference effective names + unresolved diagnostic(
OTSCRIPT_EQUIPREF),EquipmentIdthreaded request→model→JS.Address space stays dark (values light up in Batch 4). Full solution builds 0 errors; Commons 309,
Configuration 121, AdminUI 659, OpcUaServer 335/4-skip, Runtime 361/42-skip (the skips are
the Batch-1 dark-address-space tests Batch 4 un-skips).
Reviewer findings fixed in-branch
NoOp fallback only wins under
newin tests; guard invoked in all 6 colliding mutations); cluster scopingstructural + server-enforced; Razor default
RawTreebyte-unchanged; WP3 pre-save RawPath recomputation.L1 (misleading concurrency comment) fixed.
invariant now holds on the ScriptedAlarm surface too (SA predicate + message-template
{{equip}}refsvalidated at authoring, matching the deploy gate). L3 fixed: parity test hardened (unresolved-ref-intact
the RawPath (out of
{{equip}}scope;{{equip}}/completion works) — reworkScriptTagCatalogto RawPathsin Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
message-template
{{equip}}/Xis gate-validated but rendered/substituted only in Batch 4.Live
/rungate (docker-dev:9200, both central nodes rebuilt on Batch-3 code)Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is
demonstrable. (Hand-seeded equipment surfaced the Batch-1
EquipmentIdNotDerivedinvariant — the canonicalEQ-<hash>id was applied before the gate.)only the Main cluster root shows (SITE-A/B absent — the seeded
SiteAOnlytag unreachable); lazyexpansion; a device "Select all tags below" pulled tags across a collapsed group → 4 references in
one commit. Rows show effective name / RawPath (incl. deep
opcua1/plc/OpcPlc/Telemetry/Basic/…ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated
HR200 → MainPressure, RawPath unchanged."effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'" (not persisted).
rename-induced: renamed a raw tag so two references collide (allowed at rename) → deploy 422
[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…', reference '…'.{{equip}}(editor ⇔ deploy) — resolving{{equip}}/MainPressuredeploys 202; misspelled{{equip}}/MainPresuredeploys 422[EquipReferenceUnresolved] … has no reference named 'MainPresure'.Monaco: red squiggle + hover "'{{equip}}/MainPresure' does not resolve — … no reference named
'MainPresure' … (OTSCRIPT_EQUIPREF)"; typing
{{equip}}/completes the four reference effectivenames (AlternatingBoolean, ImportedTag, MainPressure, RandomSignedInt32).
UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming
an unrelated driver → no warning dialog. (Note: direct tag rename flows through
UpdateTagAsyncwhich has no warnings surface — warnings fire on container renames affecting descendants; documented
follow-up if per-tag-rename warnings are wanted.)
Name, MachineCode, UnsLineId(+ optional
ZTag, SAPID, Manufacturer, Model); noDriverInstanceId.Docs
docs/Uns.mdTags section rewritten to the reference-only model;docs/ScriptEditor.mdupdated (WP4) to theslash/reference
{{equip}}semantics;CLAUDE.mdgains a v3 Batch 3 paragraph.https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now raises three warning kinds over the affected (prefix-scanned) tags: (a) refined — historized WITHOUT a historianTagname override (default tagname is the moving RawPath, so history forks); a pinned (override) tag no longer warns. Count/message adjusted. (b) unchanged — UNS-referenced (names the equipment). (c) NEW — substring-scan every Script body for the affected tag's OLD RawPath (ordinal, false-positives tolerated by design; no AST). OLD RawPaths are recomputed from the pre-save in-DB topology via RawPathResolver. CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs; IRawTreeService + RawTree.razor untouched (warnings flow through the existing RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync (UnsMutationResult, no warning surface) so it is out of scope without widening the interface. Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn, unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmoxReplace the deleted equipment-base-prefix {{equip}} mechanism (impossible now that equipment is reference-only) with per-reference resolution: {{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows (by effective name) to the backing raw tag's RawPath. Every unresolved <RefName> is a clear deploy error + authoring rejection + Monaco diagnostic — preserving "editor accepts <=> publish accepts". Commons: - EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now takes the equipment's reference map (effectiveName -> RawPath) and substitutes {{equip}}/<RefName> inside path literals (unresolved left intact, never throws); add ExtractEquipReferenceNames (path-literal scoped) + ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces the v2 dot joint. - New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON sides) over RawPathResolver — the single authority both compose seams + the validator use, so resolved RawPaths agree byte-for-byte. Compose seams (byte-parity kept): - AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build the per-equipment reference map from UnsTagReferences + Tags + raw topology and substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency extraction (runtime dep refs = resolved RawPaths). Deploy gate + authoring + editor: - DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm message-template tokens (naming script, equipment, missing ref). - UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic. - ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor -> monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter). Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<-> artifact reference-resolution byte-parity; DraftValidator unresolved-ref; ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all five affected suites green. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmoxMEDIUM-1: the editor<->authoring<->deploy invariant now holds on the ScriptedAlarm surface too. CreateScriptedAlarmAsync/UpdateScriptedAlarmAsync validate {{equip}}/<RefName> in BOTH the predicate script and the message template (via ExtractEquipReferenceNamesFromText), matching what DraftValidator already enforces at deploy. Refactored ValidateEquipTokenAsync into shared LoadEquipReferenceNamesAsync + CheckEquipReferencesResolve helpers. LOW-1: LoadEquipReferenceNamesAsync uses IsNullOrEmpty (defense-in-depth) to match EquipmentReferenceMap.Build; empty override is already normalized->null at the write path. LOW-3: parity test hardened with two edge cases — unresolved-ref-left-intact (both seams leave {{equip}}/X identical) and folder+tag-group RawPath ancestry. Tests: VirtualTagEquipTokenValidationTests 9/9 (+3 SA), parity 4/4 (+2). AdminUI 659/0. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmoxUns.md Tags section rewritten to the reference-only model (UnsTagReference list, cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox