Files
lmxopcua/docs/Raw.md
T
Joseph Doherty 0b537b5dec docs: record surgical-deploy semantics + artifact compression ops notes (plan 2026-08-15)
CLAUDE.md — a new "How a deploy reaches that address space" block under the v3
Batch 4 address-space section. The blanket ChangedRawTags/ChangedUnsReferenceVariables
=> Rebuild rule is gone, so the doc now names both halves explicitly: what still
rebuilds (alarm-bearing raw-tag deltas, raw-tag identity changes, raw container
renames/removals, changed equipment/alarms) and what is now surgical (non-alarm
attribute deltas, every UnsReferenceDelta). Also records the three semantics that
are easy to break by accident: mirror repair rides UnsReferenceVariable's inherited
backing fields (omitting a newly-inherited field silently reopens the staleness),
a degraded non-rebuild apply deliberately does not advance _lastApplied, and the
raw-realm announce gap is no longer neutral now that surgical adds keep
subscriptions alive.

CLAUDE.md ConfigSource — the RevisionHash == SHA-256(artifact bytes) paragraph is
still true and stays; it gains the clause that the blob is gzip-compressed since
2026-08-15 and the hash covers the COMPRESSED bytes, which is precisely why the
fetcher, cache and serve path were untouched.

docs/Uns.md + docs/Raw.md — one section each on what a deploy now tears down, from
the authoring surface's point of view, including the mirror repair semantics
(identity-preserving backing edits update in place and keep their MonitoredItems;
re-points and effective-name edits remove+recreate that one node).

docs/Configuration.md — "Artifact compression (2026-08-15)" under ConfigSource/
ConfigServe: gzip inside the hashed bytes, magic-sniffed on read, pre-upgrade
uncompressed rows still parse, plus the two operational facts an operator needs.
The first deploy after upgrade re-hashes every revision (same config, new bytes,
new RevisionHash) so it is a one-time full re-dispatch rather than a no-op; and
rollout is ordered — nodes must run artifact-sniffing code BEFORE central starts
emitting compressed blobs, since a pre-upgrade node handed a gzip artifact fails
the apply and keeps last-known-good until it is upgraded.

Plan doc — a "Follow-ups (recorded, not implemented)" section covering the seven
items surfaced during implementation and review: the raw-realm announce gap, the
RemoveEquipmentSubtree dangling-inverse-edge exposure, the missing end-to-end test
for a degraded remove-bearing apply's ratcheted rebuild, the scripted-alarm
thin-plan-ids-against-rich-rows blindness, the absent monotonic dispatch sequence,
LocalDb store ordering under concurrent StoreAsync, and threading the in-hand blob
through RestoreApplied.

tasks.json — all nine tasks completed.

Docs only; no source changes.
2026-08-15 18:02:21 -04:00

134 lines
8.3 KiB
Markdown

# The Raw project tree (`/raw`)
v3 authors device I/O in a **Raw project tree** — a cluster-rooted, Kepware-style hierarchy
that is the single surface for driver/device/tag authoring. It is the peer of the `/uns`
unified-namespace page; the two OPC UA namespaces (`ns=Raw` / `ns=UNS`) light up in Batch 4.
```
Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag
```
Every node's identity is its **RawPath** (`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`,
cluster-scoped, `/`-separated, ordinal). Names are validated as RawPath segments (no `/`, no
leading/trailing whitespace) at authoring and at the deploy gate.
## The tree
`/raw` (`GlobalRaw.razor` → `RawTree.razor`, backed by `IRawTreeService`) lazily loads each
container level on expand (unlike `/uns`, which eager-loads). Each node has a right-click
context menu **and** a `⋯` fallback (the reusable `ContextMenu` component):
| Node | Actions |
|---|---|
| Cluster | New folder · New driver |
| Folder | New folder · New driver · Rename · Delete |
| Driver | Configure · New device · Enable/Disable · Rename · Delete |
| Device | Configure (endpoint + **Test connect**) · New tag-group · Add tags ▸ · Delete |
| TagGroup | New group · Add tags ▸ · Rename · Delete |
| Tag | Edit · Delete |
Deletes are blocked while a node has children (or, for a raw tag, while a `UnsTagReference`
points at it — the error names the referencing equipment). Renames return non-blocking
**warnings**: renaming a node changes the RawPath of every historized / UNS-referenced tag
beneath it (its historian tagname / UNS projection path moves).
**A rename rebinds live data on the deploy — no node restart is needed** (Gitea #489, live-gated
2026-07-31 for both a tag rename and a device rename). The mechanism is #516's respawn: a rename
changes the merged `DriverConfig`, and `DriverSpawnPlan.Compute` stops and respawns the driver child
on any `DriverConfig` change, so the driver rebuilds against the new RawPaths and re-subscribes.
⚠️ The flip side is that **a rename reconnects the driver** — briefly dropping every tag on it, not
just the renamed one. That is the accepted cost #516 documents; it is not specific to renames.
## Endpoint → DeviceConfig (the channel/device split)
v3 moves the **connection endpoint** off the driver and onto the **Device** (`DeviceConfig`),
mirroring Kepware's channel/device split. The driver's `DriverConfig` holds protocol/channel
settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe time
`DriverDeviceConfigMerger` merges the device's endpoint up into the driver config, and Test
connect (inside the **Device** modal) probes that merged config. Driver forms no longer
serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
## Authoring tags
**Add tags ▸** on a Device or TagGroup offers:
- **Manual entry** — a grid of Name / DataType / AccessLevel / WriteIdempotent / PollGroup +
the driver-typed `TagConfig` editor per row.
- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row
verdicts → commit) over an RFC-4180 parser. Columns: the common set
(`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed
columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath`
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
Export round-trips the same shape.
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
target, with the browsed reference written into the driver-typed `TagConfig` **address
field** (`nodeId` / `tagPath` / …), never an identity key. An opt-in "create matching
tag-groups" toggle mirrors the browse folder nesting. The browser dials a real ephemeral
driver against the **merged** Driver+Device config.
## What a deploy tears down (surgical deploys, 2026-08-15)
Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm**
raw-tag delta whose only differences are the in-place-updatable attributes —
`Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength` —
and/or its set of referencing equipment is applied **in place** on the existing Raw-realm
node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on
that tag and on the rest of the server survives. The same is true of **every** UNS
tag-reference change: adding, removing or re-pointing a reference used to flip the
backing raw tag to Changed (the referencing set is part of its record equality) and
rebuild the whole address space for a one-variable edit.
**Still a full rebuild**, deliberately: an **alarm-bearing** raw tag (a delta with
`Alarm != null` on either side — only a rebuild clears and re-wires the native-alarm
notifiers that fan the condition to its referencing equipment folders); an **identity**
change on a tag (`TagId`, `DriverInstanceId`, `Name`, `ParentNodeId`, realm); and any
change to a raw **container** — a Folder / Driver / Device / TagGroup **rename or
removal** — because the surgical sink has no folder-remove. The classifier is
default-closed: any field it does not explicitly whitelist, including fields added
later, still routes to a rebuild, and any surgical call returning `false` or throwing
ratchets back to one.
**Mirror repair.** A raw-side historian or shape edit also reaches every UNS reference
mirroring the tag: the reference record carries the backing tag's historian/shape, so
the edit emits a coincident UNS-reference delta. Identity-preserving repairs update the
mirror **in place** (its MonitoredItems survive); a re-point or effective-name change
removes and recreates **that one mirror node**. See [`Uns.md`](Uns.md) and
[`plans/2026-08-15-surgical-deploys.md`](plans/2026-08-15-surgical-deploys.md).
⚠️ A surgically added raw container/tag is materialised but **not** announced —
`AnnounceAddedNodes` is UNS-only, so model-aware clients see new raw nodes on their next
browse. This mattered less when every add forced a rebuild (and therefore a re-browse).
## The `Calculation` driver
Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driver
(`DriverTypeNames.Calculation`), computed by C# scripts that read other tags' live values via
`ctx.GetTag("<RawPath>")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for
the full design. Highlights:
- ⚠️ **Correction (2026-07-28): the "auto-created default `Engine` device" below never shipped.**
`"Engine"` has zero occurrences in `src/`, and nothing special-cases `Calculation` at device
creation — author the device like any other. Per-tag `TagConfig` is
`{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`.
- The host feeds dependency values via the new `IDependencyConsumer` capability + a
`DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work
because a calc output re-enters the mux. The driver rebuilds its tag/dependency table on
every redeploy (`ReinitializeAsync`) and re-registers its refs after the delta applies.
- **Deploy gates** (`DraftValidator`): `scriptId` existence, and a hard **cycle gate**
(`DependencyGraph.DetectCycles` over calc→calc edges; cross-driver refs are terminal) that
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
## Retirement note
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
`DriverEditRouter`, the 8 per-type `*DriverPage` shells, and the per-cluster Drivers list) is
**retired** — authoring lives in `/raw`. The extracted driver/device **form bodies** live on
inside the `/raw` modals. The `DriverType` dispatch maps (`TagConfigEditorMap`,
`TagConfigValidator`, `EquipmentTagConfigInspector`) are keyed off the single-source-of-truth
`DriverTypeNames` constants (fixing the historical `TwinCat`/`Focas` drift).