# 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).

## 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** | ADS native array symbol read against the declared `SymbolPath` | Unit-proven (sim fixture down) |

### Out of scope (named deferrals)

- **Array writes** (inbound client→device write of an array value) — tagged for a follow-up phase.
- **Multi-dimensional arrays** (`ValueRank > 1`) — not supported; all arrays are 1-D.
- **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` LDAP role gate). 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 (LDAP `WriteOperate`) 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.

## 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).
