Files
lmxopcua/docs/Uns.md
T

564 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UNS — Global Unified-Namespace Management
The **UNS** page (`/uns` in the AdminUI) is the single surface for managing
the Unified Namespace across the whole fleet. It replaces the old
per-cluster **UNS**, **Equipment**, and **Tags** tabs and the standalone
virtual-tags list — those have been removed; everything now lives in one
global master tree.
## The tree
The page shows every layer of the UNS as one expandable tree. Equipment is a
**leaf** in the tree — tags, virtual tags, and alarms live on a dedicated
equipment page, not inline:
```
Enterprise (read-only grouping — ServerCluster.Enterprise)
└─ Site / Cluster (read-only grouping — a ServerCluster row)
└─ Area (editable — UnsArea)
└─ Line (editable — UnsLine)
└─ Equipment (leaf — Equipment; opens its own page)
```
**Enterprise and Site/Cluster can be deleted from the tree** (see
[Cluster and Enterprise delete](#cluster-and-enterprise-delete) below).
Other than deletion, they are configured on the **Clusters** pages
(`/clusters`) — on a cluster row the **⚙ settings** link jumps to that
cluster. Editable UNS entities start at **Area**.
Count badges next to a node show how many direct children it has (for
equipment, the combined tag + virtual-tag count).
### Navigating
- **Expand all / Collapse all** toggle the structural levels.
- **Filter by name** does a case-insensitive substring match on the names of
a node's direct children.
## Creating and editing
Every editable row has inline actions:
| Node | Actions |
|---|---|
| Cluster | **+ Area**, Delete |
| Area | **+ Line**, Edit, Delete |
| Line | **+ Equipment**, Edit, Delete |
| Equipment | **Open**, Delete |
A **+ Child** action pre-fills the parent for you (e.g. **+ Line** on an
area opens the Line modal with that area already selected). Build a branch
top-down: Area → Line → Equipment. Tags, virtual tags, and scripted alarms
are managed on the equipment page (see below).
### Naming: area, line and equipment names are UNS path segments
An area, line or equipment **name** becomes a level of the OPC UA NodeId
(`Area/Line/Equipment/EffectiveName`), so it must match
**`^[a-z0-9-]{1,32}$`** — lowercase letters, digits and dashes, 1–32
characters. The reserved `_default` is also accepted. `Packaging` and
`Line 1` are not valid; `packaging` and `line-1` are.
**Ids are a different thing and a different rule.** `UnsAreaId` /
`UnsLineId` are opaque handles that never reach a NodeId, so they take
`^[A-Za-z0-9_-]+$` — `SITEA-area1` is a fine id for an area named
`packaging`.
The rule is enforced in `UnsTreeService` on **every** authoring path —
create, rename, and the CSV import — and again at the deploy gate
(`UnsSegmentInvalid`). Both call the same `UnsSegment` helper, so they
cannot disagree.
⚠️ **Why it is enforced at authoring and not only at deploy (Gitea #535):**
until 2026-08-01 only the equipment form checked it, so an area or line
could be named `Packaging`, save cleanly, and fail the *next deployment* —
and because a deployment snapshots the **whole database**, one badly-named
area blocked **every** cluster's deploy, usually discovered by someone other
than whoever typed it. In the CSV import a bad name is reported as an
**error**, not a skip: a skip means "you already have this row", which would
say the equipment exists when it does not.
## Equipment page (`/uns/equipment/{id}`)
Clicking **Open** on an Equipment row (or **+ Equipment** on a Line) navigates
to the equipment page. The page is organised into four tabs:
| Tab | Content |
|---|---|
| **Details** | Equipment identity — name, description, OPC-40010 namespace fields, driver binding, and served-by cluster. (This is the former EquipmentModal, now a full page.) |
| **Tags** | Equipment-bound driver tags. Each tag uses the driver-typed config editor (same editors as before — Modbus, S7, AB CIP, etc.) with the same client-side validation. |
| **Virtual Tags** | Virtual tags driven by C# scripts. The inline Monaco script editor with Roslyn IntelliSense, completions, and live diagnostics is available here per tag. |
| **Alarms** | Scripted-alarm definitions bound to this equipment. Create, edit, or delete predicates here; the Monaco editor is available for each predicate script. |
**"Add equipment"** under a Line uses the URL `/uns/equipment/new?lineId=...`,
pre-filling the parent line. Saving redirects to the new equipment's page.
### Served-by cluster
An area's **cluster assignment is its "served-by" cluster** — the cluster
node that runs it. It's set when you create the area (under a cluster) and
changed by editing the area's cluster in the Area modal, which moves the
whole branch. There is no separate "served-by" concept and no migration —
it is simply `UnsArea.ClusterId`.
### Tags — reference-only (v3)
> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
> equipment's **Tags** tab holds **references** to those raw tags. The old
> driver-bound Tag modal on this tab is retired.
The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
**effective name**, the backing tag's **RawPath**, its inherited **DataType** and
**AccessLevel** (read-only — they come from the raw tag), and an editable
**display-name override**. The effective name is the override else the raw tag's
`Name`.
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
**scoped to the equipment's cluster** — cross-cluster tags are structurally
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
tags below"* menu to pull many at once.
**Effective-name uniqueness:** within an equipment the effective name must be
unique across references, VirtualTags, and ScriptedAlarms (they share the
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
both at authoring (a readable rejection naming the colliding source) and at the
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
what catches **rename-induced** collisions a raw rename produced after the
reference was authored, naming both colliding sources and the equipment.
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
(the error names the referencing equipment); renaming a raw tag or any ancestor
warns when a beneath-it tag is historized (no `historianTagname` override),
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
>
> | Namespace URI | Subtree | NodeId `s=` scheme |
> |---|---|---|
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
Every device value has **exactly one source** — the raw tag's node in the Raw
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
(the display-name override else the raw tag's `Name`). The UNS variable does not
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
raw NodeId AND every referencing UNS NodeId with identical value / quality /
source-timestamp — the two NodeIds never drift.
- **Reads / subscriptions** work through either NodeId and return the same data.
- **Writes** route through either NodeId to the same backing driver ref under the
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
than the raw write it fans from); a failed device write reverts both NodeIds via
the shared fan-out.
- **HistoryRead** works through either NodeId and returns the same series — both
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
fan via SDK notifiers to the raw device folder AND every referencing equipment
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
Note the two distinct identity strings: the wire **NodeId** is the path
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
space the guards enforce). They are related but not the same string.
### Virtual tags
A virtual tag is bound to an equipment and driven by a **script** (no driver).
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
data type is chosen from the standard OPC UA type list and the Monaco script
editor is available inline. Scripts may read the equipment's references
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
resolves through the equipment's `UnsTagReference` rows (by effective name) to
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
[`ScriptEditor.md`](ScriptEditor.md).
### Galaxy tags
`GalaxyMxGateway` is an **Equipment-kind driver** — Galaxy points are ordinary
equipment tags authored through the standard Tag modal, exactly like Modbus or
S7 tags. The Galaxy reference is stored as `TagConfig.FullName`
(`tag_name.AttributeName`). The Galaxy address picker on the Tags tab lets you
browse the live Galaxy hierarchy to select an attribute; after selecting, set
the tag **Name**, **DataType**, and **AccessLevel** (default: read-only). There
is no alias concept, no `SystemPlatform`-kind namespace, and no relay→alias
converter.
## Typed TagConfig editors
The **Tag modal** dispatches a driver-typed config editor for the following
drivers:
| Driver | Fields in the typed editor |
|---|---|
| Modbus | Register type, address, data type, word order, etc. |
| S7 | Data block, offset, data type, etc. |
| AB CIP | Tag path, data type, etc. |
| AB Legacy (DF1/DH+) | Address, data type, etc. |
| TwinCAT | Symbol path, data type, etc. |
| FOCAS | PMC address, data type, etc. |
| **OpcUaClient** | `FullName` (the remote OPC UA node id string) |
**OpcUaClient** was previously raw-JSON fallback only; it now has a first-class
typed editor that exposes a single `FullName` field (PascalCase JSON key,
consistent with the Galaxy editor convention). It is registered in
`TagConfigEditorMap` and `TagConfigValidator`; unknown keys in the stored JSON
blob are preserved on round-trip.
> The historian backend is the external HistorianGateway (no OtOpcUa-side tag
> driver / tag-config editor). See [Historian.md](Historian.md).
Drivers not yet listed above (e.g. Galaxy — which uses the Galaxy address
picker described below) still use the generic raw-`TagConfig`-JSON textarea.
### "Build address" pickers in protocol-driver editors
The **Modbus, S7, AB CIP, AB Legacy, TwinCAT, and FOCAS** typed editors
include a **Build address** button. Clicking it opens the driver's existing
address-builder UI inside the shared `DriverTagPicker` overlay; confirming a
selection writes the fully constructed address string back into the editor's
address field. This means you can visually compose a register reference
(e.g. select "Holding Register → 100 → Int16") and have it serialised into
the correct JSON without hand-editing.
## Historizing tags (first-class controls)
The Tag modal exposes **Historize this tag** and **Historian tagname
(override)** as explicit controls that work for **all drivers** — including
protocol drivers (Modbus, S7, etc.) that use the typed editor and raw-JSON
drivers (Galaxy) alike.
| Control | JSON key | Type | Behaviour |
|---|---|---|---|
| **Historize this tag** checkbox | `isHistorized` | bool | When checked, the OPC UA node materialises with `Historizing=true` and the `HistoryRead` AccessLevel bit set. |
| **Historian tagname (override)** textbox | `historianTagname` | string (optional) | Explicit tagname the historian backend will query. When left blank, the server defaults to the tag's driver `FullName`. |
These fields are merged into the `TagConfig` JSON blob via the pure
`TagHistorizeConfig` helper, which preserves all other keys byte-stable.
The server's OPC UA HistoryRead dispatch already consumes these keys from the
`TagConfig` blob — see [Historian.md](Historian.md) for the full server
behaviour, continuation-point paging, and aggregates.
> **Note — native-alarm `HistorizeToAveva`:** a tag that carries a native
> `"alarm"` object has a **separate** opt-out field `alarm.historizeToAveva`
> (a checkbox labelled "Historize to AVEVA" on the Tag modal's alarm section).
> That field controls whether the alarm's **transition events** are written to
> the AVEVA historian — it does not affect tag-value history (which is
> controlled by `isHistorized`). See [ScriptedAlarms.md §Native driver
> alarms](ScriptedAlarms.md#native-driver-alarms-equipment-tag-path) for
> details.
## Array tags (1-D)
A tag can be made a **1-D OPC UA array node** by setting two keys in its `TagConfig`
JSON blob. The controls are exposed as first-class UI fields in the Tag modal (an
**Is array** checkbox + an **Array length** numeric input), available for all drivers
— typed editors (Modbus, S7, etc.) and the raw-JSON textarea (Galaxy) alike.
### Canonical rule
| Condition | OPC UA node shape |
|---|---|
| `isArray: true` AND `arrayLength >= 1` | 1-D array node (`ValueRank = OneDimension`, `ArrayDimensions = [arrayLength]`) |
| `isArray: false` (any `arrayLength`) | Scalar node (default) |
| `isArray` absent | Scalar node (default) |
A single-element array (`isArray: true, arrayLength: 1`) is valid and materialises as a
`[1]` node.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `isArray` | bool | no | When `true` (and `arrayLength >= 1`), makes the node a 1-D array. Absent or `false` → scalar. |
| `arrayLength` | uint (≥ 1) | when `isArray: true` | The element count for the OPC UA `ArrayDimensions` declaration. Must be ≥ 1 when `isArray` is set. |
UI validation rejects `arrayLength = 0` when `isArray` is checked.
### Examples
```json
{"FullName":"PLC1.TemperatureArray","isArray":true,"arrayLength":10}
```
```json
{"Register":"HR100","DataType":"Float32","isArray":true,"arrayLength":5}
```
Combined with historization (values are arrays — history of the whole array snapshot):
```json
{"Register":"HR200","DataType":"Int16","isArray":true,"arrayLength":20,"isHistorized":true}
```
### Per-driver read mechanism and live-verify status
| Driver | Read mechanism | Live-verify |
|---|---|---|
| **Modbus** | Contiguous FC03/FC04 block (`arrayLength × registers-per-element`); String and BitInRegister array modes also supported | Mac-verifiable (sim `10.100.0.35:5020`) |
| **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) |
| **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) |
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
### Array writes (#610)
An authored-`ReadWrite` array tag is **writable only when its driver can write that array** —
the server never advertises a write the driver will refuse. The capability is one table,
`DriverArrayWriteSupport` (`Core.Abstractions`), consulted by the composer at compose time and
carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS reference as
`UnsReferenceVariable.BackingArrayWritable`); the node is writable iff
`Writable && (!IsArray || ArrayWritable)`, on the raw node and every UNS mirror alike.
| Driver | Array writes | Element types |
|---|---|---|
| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
scalar encoder cannot take an array), even though the composer still materialises an array node;
that node stays read-only.
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
fails a test until the table follows.
What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array,
a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused
inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the
check the OPC UA SDK would normally make itself: it skips its own type check on any node with an
`OnWriteValue` handler, which every writable equipment node has, so the node manager makes it
instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was
written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep
their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge)
write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder
converts with a range check, so an out-of-range scalar fails the device write and is reverted.
- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline.
- A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it.
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
device-refused array write is reverted on the NodeId it was written through (the scalar
write-outcome self-correction, unchanged).
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`.
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
> shape the table above accepts is materialised **writable** — no deployment needed, since the
> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
> other drivers are unaffected.
### Out of scope (named deferrals)
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
not merely deferred. Every driver's array read decodes a flat 1-D block (TwinCAT already
publishes multi-dim PLC arrays flattened), and the node manager materialises every array
`ValueRank = OneDimension` with a single `ArrayDimensions` entry. Supporting rank > 1 would
need per-rank dimensions in TagConfig, the plan records, the classifier whitelist, the node
manager and every driver's decode/encode, and no consumer has asked for it. Author a
multi-dimensional PLC array as a flat 1-D tag (row-major), or as several 1-D tags.
- **Array historization** — a historized array tag materialises with the correct `Historizing` flag, but the historian backend treats the value as an opaque blob; per-element history is out of scope. (Continuous historization is numeric-analog only — array / non-numeric values are not recorded.)
See the individual driver docs under `docs/drivers/` for per-driver implementation details.
## Writable equipment tags (`writable` key)
An equipment tag is **writable by default** — the materialised OPC UA node is created
`CurrentReadWrite` and inbound client writes are routed to the device (subject to the
`WriteOperate` gate — a role the session's API-key principal earns from its `NodeAcl` grants, see
[security.md](security.md)). Setting the optional `"writable": false` key in a tag's
`TagConfig` makes the node **read-only** at the driver seam. The six typed tag editors
(Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) expose this as a **Writable** checkbox
(checked by default); it can also be hand-authored in the raw-JSON textarea.
| Condition | Node |
|---|---|
| `writable` absent | Writable (driver default) — existing configs are unchanged |
| `writable: false` | Read-only |
| `writable: true` | Writable |
The key is honoured by Modbus, S7, AB CIP, AB Legacy, and TwinCAT. **FOCAS forces
read-only regardless** — FOCAS PMC writes are unsupported in the wire client, so its
editor shows the checkbox disabled/unchecked and any authored `writable:true` is ignored
(and flagged at the deploy gate below). Node-level authz (`WriteOperate` via the API-key principal's grants, not LDAP) remains the
effective write gate above this flag.
## Deploy-time TagConfig validation (`Deployment:TagConfigValidationMode`)
The deploy gate inspects every equipment tag's `TagConfig` per its bound driver type and
surfaces **strictness warnings** — a present-but-invalid enum value (e.g. a typo'd
`dataType`, which the lenient runtime silently defaults to the wrong width with `Good`
status), a structurally-unparseable TagConfig, or (FOCAS) an authored `writable:true`.
The behaviour is controlled by `Deployment:TagConfigValidationMode` in `appsettings.json`:
| Mode | Behaviour |
|---|---|
| `Warn` (default) | Non-blocking: warnings are logged and appended to the deployment result message; the deploy still succeeds. Existing configs never break. |
| `Error` | Blocking: warnings fold into the draft-gate reject list — a config with a typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment once the fleet deploys warning-clean. |
Running servers are untouched either way — the gate only sees re-deploys. Unmapped drivers
(Galaxy, OpcUaClient) are skipped. **Unknown keys are never warned on**: the `TagConfig`
blob is a shared namespace (platform intent keys coexist with per-driver address keys and
forward-compat keys the typed editors preserve), so there is no single schema to validate
against — parsers read known fields and ignore the rest by design.
> **Note (runtime strictness is deferred).** In this release the runtime parsers stay
> *lenient* (a typo'd enum still silently defaults) — only the deploy gate reports it.
> Flipping the runtime parsers to strict (a typo'd enum ⇒ `BadNodeIdUnknown` instead of a
> wrong-width `Good`) is a deliberate future phase, to be taken only after fleets have run
> `Error` mode clean. Tracked in `archreview/plans/STATUS.md`.
## Galaxy address picker — native-alarm pre-fill
When the Galaxy address picker selects an attribute that is itself an alarm
(`IsAlarm == true` in the Galaxy hierarchy), the Tag modal automatically
seeds a default `alarm` object in the tag config:
```json
{"alarmType":"OffNormalAlarm","severity":700}
```
This lets the operator author the native alarm in a single picker pass
without hand-editing JSON. The pre-fill **never overwrites** an alarm object
that is already present — if the tag already has a custom `alarm` section, the
picker leaves it untouched.
## Cluster and Enterprise delete
### Cluster delete
A **Delete** action is available on Cluster rows in the UNS tree. The server
refuses the delete if the cluster still has any Areas (children) — the same
refuse-if-children guard used by Area and Line delete. Remove all Areas (and
their descendant Lines/Equipment) first, then delete the cluster.
> **No RowVersion concurrency check.** `ServerCluster` does not carry a
> concurrency token, so the delete does not have the last-writer-wins protection
> that Area/Line/Equipment deletes have. A follow-up migration will add the
> token; for now, coordinate cluster deletes manually.
### Enterprise delete
An **Enterprise** row is a read-only grouping label (the `Enterprise` column
of `ServerCluster`) — it is not a separate entity. Deleting an Enterprise row
**deletes all clusters whose `Enterprise` matches that label**, all-or-nothing:
- If any cluster under the enterprise still has children, the entire delete is
refused and no clusters are removed.
- If all clusters are empty, every cluster under that enterprise is deleted in a
single transaction.
Remove all Areas under every cluster in the enterprise first, then delete the
enterprise label.
## Create-new-script inline (virtual-tag panel)
On the equipment page's **Virtual Tags** tab, when a virtual tag is not yet
bound to any script, the inline script panel shows a **Create new script**
button. Clicking it:
1. Generates a new blank Script record with an auto-generated `SC-…` id.
2. Binds the virtual tag to that script.
3. Expands the Monaco editor inline so you can begin authoring immediately.
This removes the previous two-step flow (create a script on a separate page,
then attach it to the virtual tag); the entire lifecycle now lives on the
equipment page.
## Hosts page per-driver-instance rows (deferred)
Phase 6 did **not** implement per-driver-instance status rows on the Hosts
page. This item (H7-runtime) is **F7-runtime-blocked**: the runtime plumbing
needed to surface per-instance health rows is not yet in place. It remains on
the backlog.
## Bulk import
**Import equipment CSV** (toolbar) bulk-creates equipment across many lines
and clusters in one pass. After an import the whole tree reloads.
## Applying changes
Edits here change the configuration only. As the page header notes,
**changes apply on the next deployment** — run a **Deploy** (Deployments
page) to push them into the running address space.
### What a deploy actually tears down (surgical deploys, 2026-08-15)
Adding, removing or re-pointing a UNS **tag reference** no longer rebuilds the
address space. It used to: `RawTagPlan.ReferencingEquipmentPaths` participates in
the backing raw tag's record equality, so touching one reference flipped that tag to
Changed and the classifier's blanket `ChangedRawTags ⇒ Rebuild` rule tore down and
recreated **every** node on the server — killing every client's subscriptions for a
one-variable edit. Now **every** `UnsReferenceDelta` is applied surgically, and so is
a **non-alarm** raw-tag delta over the in-place-updatable attributes
(`Writable` / `IsHistorized` / `HistorianTagname` / `DataType` / `IsArray` /
`ArrayLength`). A **full rebuild** is still forced by an **alarm-bearing** raw-tag
delta (only a rebuild re-wires native-alarm notifiers), an **identity** change on a
raw tag (`TagId` / `DriverInstanceId` / `Name` / `ParentNodeId` / `Realm`), a raw
**container** rename or removal, and any changed equipment / scripted alarm. The
classifier stays default-closed — anything its whitelist does not name still rebuilds,
and any surgical failure ratchets back to a rebuild.
**Mirror repair.** A UNS reference variable mirrors its backing raw tag, so a raw-side
historian or shape edit must reach it too — `UnsReferenceVariable` carries the backing
tag's `IsHistorized` / `HistorianTagname` / `IsArray` / `ArrayLength`, which makes the
reference record unequal on such an edit and emits a **coincident** `UnsReferenceDelta`
alongside the raw one. **Identity-preserving** repairs (the mirror keeps the same
NodeId, equipment and backing RawPath) are applied **in place**, so the mirror's
subscriptions survive an operator historizing or reshaping the tag behind it. Only an
**identity change** — a re-point to a different raw tag, or an effective-name/display-name
edit that moves the NodeId — is a remove + recreate, and it affects **that one node**,
not the tree.
⚠️ Two consequences worth knowing. A surgically **added** UNS reference variable *is*
announced (Part 3 `GeneralModelChangeEvent`), but an added **raw** container/tag is
not — clients pick those up on their next browse. And a **degraded** non-rebuild apply
(some node failed to materialise) deliberately does not record its composition as
applied, so the next deploy re-diffs and repairs it.
## Quality on a UNS node
A UNS reference variable mirrors its backing raw tag exactly — value, status and timestamps — so when
that tag's driver stops communicating the mirror goes **`BadNoCommunication` with its last value
retained**, in the same fan-out message as the raw node, and clears only when the driver re-publishes.
A VirtualTag on the same equipment follows the same rule from its own inputs: it holds while any input
is Bad and reports that input's sub-code. The contract, and every seam that implements it, is stated
once in [`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract);
what a script sees is in
[`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all).
## See also
- [Configuration.md](Configuration.md) — the underlying config entities.
- [VirtualTags.md](VirtualTags.md) — the scripting/virtual-tag engine.
- [ScriptedAlarms.md](ScriptedAlarms.md) — scripted-alarm engine internals (predicates, state machine, ack/shelve).
- Design + decision log: [plans/2026-06-08-global-uns-management-design.md](plans/2026-06-08-global-uns-management-design.md).