v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> resolution #471

Merged
dohertj2 merged 14 commits from v3/batch3-uns-rework into master 2026-07-16 08:59:04 -04:00
Owner

v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> reference-relative resolution

Implements docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md (WP1–WP4), executed via the
/v3-batch 3 coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on
docker-dev.

What landed

  • ContractsIEffectiveNameGuard (authoring-time effective-name uniqueness seam; WP2 implements,
    WP1 consumes).
  • Wave A (3 parallel agents):
    • WP1 — equipment Tags tab → UnsTagReference list (effective name / RawPath / inherited
      DataType+AccessLevel / display-name override); new AddReferenceModal reusing RawTree in opt-in
      PickerMode (multi-select tag leaves + device/tag-group "Select all tags below"), cluster-scoped
      (structural + server-enforced tag.cluster == equipment.cluster); UnsTreeService reference CRUD;
      consumes IEffectiveNameGuard in all colliding mutations; ImportEquipmentModal/EquipmentInput
      dropped DriverInstanceId; deleted the retired equipment TagModal.
    • WP2EffectiveNameGuard (injectable, ordinal) + DI registration; verified the deploy-gate
      UnsEffectiveNameCollision rule (already computes ref effective name as override-else-current-raw-name,
      so it catches rename-induced collisions; ordinal; names both sources + equipment).
    • WP3RawTreeService.BuildRenameWarningsAsync: refined historized warning (only tags without
      a historianTagname override) + UNS-referenced + new substring scan of every Script.SourceCode
      for affected tags' OLD RawPaths (recomputed pre-save via RawPathResolver, ordinal).
  • Wave B (1 integration agent):
    • WP4{{equip}}/<RefName> reference-relative resolution. EquipmentScriptPaths.DeriveEquipmentBase
      deleted; 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 VT
      scripts, 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), EquipmentId threaded 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

  • Wave A — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the
    NoOp fallback only wins under new in tests; guard invoked in all 6 colliding mutations); cluster scoping
    structural + server-enforced; Razor default RawTree byte-unchanged; WP3 pre-save RawPath recomputation.
    L1 (misleading concurrency comment) fixed.
  • Wave B — no HIGH; byte-parity + production wiring confirmed. M1 fixed: the editor⇔authoring⇔deploy
    invariant now holds on the ScriptedAlarm surface too (SA predicate + message-template {{equip}} refs
    validated at authoring, matching the deploy gate). L3 fixed: parity test hardened (unresolved-ref-intact
    • folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null).
  • Deferred (documented follow-ups): absolute-path Monaco completion still projects the raw leaf name, not
    the RawPath (out of {{equip}} scope; {{equip}}/ completion works) — rework ScriptTagCatalog to RawPaths
    in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
    message-template {{equip}}/X is gate-validated but rendered/substituted only in Batch 4.

Live /run gate (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 EquipmentIdNotDerived invariant — the canonical
EQ-<hash> id was applied before the gate.)

  1. Reference picker + list — "+ Add reference" header "The tree is scoped to the equipment's cluster";
    only the Main cluster root shows (SITE-A/B absent — the seeded SiteAOnly tag unreachable); lazy
    expansion; 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.
  2. Collisionauthoring: setting a reference override to an existing VirtualTag's name → red banner
    "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 '…'.
  3. {{equip}} (editor ⇔ deploy) — resolving {{equip}}/MainPressure deploys 202; misspelled
    {{equip}}/MainPresure deploys 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 effective
    names (AlternatingBoolean, ImportedTag, MainPressure, RandomSignedInt32).
  4. Rename warning — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced
    • named in a script literal → "Renamed — with warnings" listing all three (historized fork,
      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 UpdateTagAsync
      which has no warnings surface — warnings fire on container renames affecting descendants; documented
      follow-up if per-tag-rename warnings are wanted.)
  5. Import without driver column — the Import equipment CSV modal columns are Name, MachineCode, UnsLineId
    (+ optional ZTag, SAPID, Manufacturer, Model); no DriverInstanceId.

Docs

docs/Uns.md Tags section rewritten to the reference-only model; docs/ScriptEditor.md updated (WP4) to the
slash/reference {{equip}} semantics; CLAUDE.md gains a v3 Batch 3 paragraph.

https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox

# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/<RefName>` reference-relative resolution Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1–WP4), executed via the `/v3-batch 3` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a code-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on docker-dev. ## What landed - **Contracts** — `IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements, WP1 consumes). - **Wave A** (3 parallel agents): - **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in `PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped** (structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD; consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput` dropped `DriverInstanceId`; deleted the retired equipment `TagModal`. - **WP2** — `EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate `UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name, so it catches rename-induced collisions; ordinal; names both sources + equipment). - **WP3** — `RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without** a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode` for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal). - **Wave B** (1 integration agent): - **WP4** — `{{equip}}/<RefName>` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase` deleted; 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 VT scripts, 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`), `EquipmentId` threaded 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 - **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation. L1 (misleading concurrency comment) fixed. - **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact + folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null). - **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability); message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4. ## Live `/run` gate (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 `EquipmentIdNotDerived` invariant — the canonical `EQ-<hash>` id was applied before the gate.) 1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*; only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy expansion; 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. 2. **Collision** — *authoring:* setting a reference override to an existing VirtualTag's name → red banner **"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 '…'`. 3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled `{{equip}}/MainPresure` deploys **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 **effective** names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32). 4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced + named in a script literal → **"Renamed — with warnings"** listing all three (historized fork, 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 `UpdateTagAsync` which has no warnings surface — warnings fire on container renames affecting descendants; documented follow-up if per-tag-rename warnings are wanted.) 5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId` (+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**. ## Docs `docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph. https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
dohertj2 added 14 commits 2026-07-16 07:38:28 -04:00
Wave-A shared contract. WP2 implements + registers the guard; WP1 consumes it
in UnsTreeService reference/VirtualTag/ScriptedAlarm mutations. Ordinal, mirrors
the deploy-time DraftValidator UnsEffectiveNameCollision rule.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.

Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.

Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.

Claude-Session: 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_01LVneM3eh1UtJxEisFXgmox
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Replace 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_01LVneM3eh1UtJxEisFXgmox
Semantic collision git auto-merged: WP2 and WP4 each added a BuildReference
helper to the DraftValidatorTests partial class (same param types -> CS0111).
Kept one with the refId param name + default null, satisfying both call styles.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
MEDIUM-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_01LVneM3eh1UtJxEisFXgmox
Uns.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
docs(v3): Batch 3 PR description
v2-ci / build (pull_request) Successful in 3m36s
v2-ci / unit-tests (pull_request) Failing after 9m8s
0c65e4412e
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
dohertj2 merged commit f8f3b82ed6 into master 2026-07-16 08:59:04 -04:00
Sign in to join this conversation.