# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver

**Branch:** `v3/batch2-raw-ui` (opened from master after Batch 1 merges)
**Design authority:** v3 design §"AdminUI — the Raw project tree (`/raw`)", §"Calculated tags", the CSV appendix; `2026-07-15-calculation-driver-mini-design.md` (complete spec for B2-WP7); `2026-07-15-universal-discovery-browser-design.md` §11 (B2-WP6 only).
**Inputs from Track 0:** T0-1 `ContextMenu`, T0-2 CSV parser, T0-3 `DriverTypeNames`, T0-4 Calculation evaluator core — merge them onto this branch first if they haven't reached master.
**End state:** everything in the Raw tree is authorable end-to-end on docker-dev (tree, drivers, devices, tag-groups, tags via manual/CSV/browse, calc tags), Test-connect works, deploys succeed. **No live values are observable — Batch 4.**

## Work packages

### Wave A — tree + data service (2 agents)

**B2-WP1 — `IRawTreeService` + lazy tree data layer**

- New `AdminUI/Uns/IRawTreeService.cs` + `RawTreeService.cs` (peer of
  `UnsTreeService`): cluster-rooted queries returning children per level
  (Folder/Driver/Device/TagGroup/Tag), **paged/lazy** (per-device tag counts can be
  large), plus mutation methods (create/rename/delete per node type, move-into-folder)
  enforcing the integrity rules with user-readable errors (delete-blocked messages name
  the referencing equipment — query `UnsTagReference` joins).
- Rename methods are where the **rename warning** hooks (Batch 3 adds the script-scan;
  this batch wires the seam: rename returns a warnings list the UI shows — historized /
  UNS-referenced checks are already answerable from this batch's schema).
- Unit tests against the EF model (in-memory + the DB-backed compliance suite pattern).

**B2-WP2 — `/raw` page + tree component**

- New `Components/Pages/Raw/GlobalRaw.razor` (`/raw` route, nav entry next to `/uns`) +
  `Components/Shared/Raw/RawTree.razor` — modeled on `GlobalUns`/`UnsTree`/`UnsNode`, but
  **actually exercising the lazy plumbing** (`HasLazyChildren`/loading flags exist in
  `UnsNode.cs` and were never used; reuse the pattern or make a `RawNode` twin).
- Context menus per node type via T0-1 `ContextMenu` (menu items exactly as the design's
  list: Folder/Driver/Device/TagGroup/Tag), incl. the "⋯" fallback affordance.
- Modals are stubs in this wave (each menu action opens a placeholder) — Waves B/C fill
  them. This keeps WP2 mergeable early so later packages build on a rendering tree.
- Live-check on docker-dev before merge (no bUnit — the tree, lazy expansion, and menu
  must be seen working).

### Wave B — driver/device/tag authoring modals (3 agents, parallel)

**B2-WP3 — driver + device config modals (the page-to-modal refactor)**

- Refactor the 8 typed driver pages
  (`Components/Pages/Clusters/Drivers/{Modbus,AbCip,AbLegacy,S7,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor`)
  into embeddable form bodies: extract each page's form content into
  `Components/Shared/Drivers/Forms/<Driver>DriverForm.razor`; what stays behind is the
  `@page` route + `EditForm` + ClusterNav + DB load + navigation. New
  `DriverConfigModal.razor` hosts a form body by driver type (dispatch via T0-3
  `DriverTypeNames`). Keep the old routed pages working during v3 (they're deleted only
  when `/raw` fully replaces them — end of this batch, see WP8).
- **Device modal (new):** with Batch 1's endpoint→`DeviceConfig` move, the multi-device
  drivers' embedded device collection editors (`CollectionEditor.razor` usage inside the
  pages) become a per-Device config modal editing `DeviceConfig` JSON via typed
  per-driver device forms. Single-endpoint drivers' default device gets the same modal
  (endpoint field lives here now).
- Test-connect inside the modal reuses `DriverTestConnectButton` + the `IDriverProbe`
  path (transient config, no persistence) — probes now need the **merged
  Driver+Device config** (Batch 1's `DriverDeviceConfigMerger`).
- **Enum-serialization guard:** every form/probe serialization site uses
  `JsonStringEnumConverter` (the systemic FB-9/FB-10 bug class); add a round-trip test
  per driver form model.

**B2-WP4 — manual tag entry + tag edit modal**

- `Add tags ▸ Manual entry` on Device/TagGroup: grid of name, datatype, access,
  `WriteIdempotent`, poll group + the driver-typed `TagConfig` editor per row (reuse
  `Components/Shared/Uns/TagEditors/*` via `TagConfigEditorMap` — re-keyed to
  `DriverTypeNames` in WP8). Single-tag Edit modal for the Tag node reuses the same
  editor shell.
- Name validation inline (`RawPaths.Validate` — no `/`, no lead/trail whitespace) +
  sibling-uniqueness error surfaced from the service.
- New editor registration for `Calculation` (see WP7): `CalculationTagConfigModel`
  (`scriptId`, `changeTriggered`, `timerIntervalMs`) + editor razor reusing the
  VirtualTagModal's script dropdown/"New script"/inline Monaco panel components.

**B2-WP5 — CSV import/export**

- Uses T0-2 parser. Staged flow (all new code — the old `EquipmentImportBatch` staged
  tables were orphaned dead code and are already dropped): upload → parse → **review
  grid** (per-row validation verdicts) → commit.
- Columns per the design appendix: common
  `Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
  HistorianTagname, IsArray, ArrayLength, Alarm.AlarmType, Alarm.Severity,
  Alarm.HistorizeToAveva, TagConfigJson` + the per-driver typed columns table (Modbus
  `Region, Address, ModbusDataType, ByteOrder, BitIndex, StringLength, Writable`; S7
  `Address, S7DataType, StringLength, Writable`; AbCip `TagPath, AbCipDataType,
  Writable`; AbLegacy `Address, AbLegacyDataType, Writable`; TwinCAT `SymbolPath,
  TwinCATDataType, Writable`; FOCAS `Address, FocasDataType`; OpcUaClient `NodeId`;
  Galaxy `AttributeRef`; Calculation `ScriptId, ChangeTriggered, TimerIntervalMs`).
  Enum columns accept member names case-insensitively. `TagGroupPath` auto-creates
  nested groups. `TagConfigJson` is the fallback for unmapped keys and **merges under**
  typed columns (typed column wins on conflict — document in the review grid).
- Export produces the same shape (round-trip property test: export → import → identical
  tag set).
- Mapping layer: per-driver `CsvColumnMap` derived from the `<Driver>TagConfigModel`
  scalar surfaces — put it next to the models in `AdminUI/Uns/TagEditors/` so model and
  map evolve together; a reflection test asserts every typed column matches a model
  property.

### Wave C — browse + Calculation + canonicalization (3 agents, parallel)

**B2-WP6 — browse re-target (GATED: requires the universal `DiscoveryDriverBrowser` merged pre-v3)**

Precondition check first: `IDriverBrowser` two-tier resolution + `DiscoveryDriverBrowser`
+ `ITagDiscovery.SupportsOnlineDiscovery` exist (they do NOT as of 2026-07-15). If absent,
this package detaches to a follow-up PR and the batch gate skips its leg.

- `Browse device…` on Device/TagGroup opens the browse modal reusing the session/tree
  layer as-is (`IBrowseSession`, `BrowseSessionRegistry`, `BrowserSessionService`,
  `DriverBrowseTree.razor`); enable/disable per the two-tier gate (bespoke browser →
  universal when `SupportsOnlineDiscovery` → grayed out + tooltip).
- **Commit contract (v3):** multi-selected leaves → raw `Tag` rows under the target
  Device/TagGroup — `Name` from browse name, `DataType` from `DriverAttributeInfo`, the
  leaf's driver reference written into the driver-typed `TagConfig` **address field**
  (`nodeId` / `attributeRef` / the protocol address — NOT any identity key). Opt-in
  "create matching tag-groups" toggle mirrors browse folders onto nested `TagGroup`s.
- **Config input:** pass the merged Driver+Device config (`DriverDeviceConfigMerger`) as
  the browser's `configJson` (the universal browser dials a real ephemeral driver
  instance and needs the device endpoint).
- Name-collision note: the browser side must not reuse the name
  `CapturingAddressSpaceBuilder` — Runtime owns it
  (`Runtime/Drivers/CapturingAddressSpaceBuilder.cs`).

**B2-WP7 — `Calculation` driver (execute `2026-07-15-calculation-driver-mini-design.md` §§1–8 verbatim)**

- Project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` around the T0-4 evaluator:
  `CalculationDriver : IDriver, ISubscribable, IReadable` (not `IWritable`, not
  `ITagDiscovery`); the new **`IDependencyConsumer`** capability interface in
  `Core.Abstractions` (exact shape in mini-design §3); `DriverHostActor` wiring (~30
  lines): when a spawned driver implements it, spawn a mux-adapter child that
  `RegisterInterest(driver.DependencyRefs, Self)` on `DependencyMuxActor` and forwards
  `DependencyValueChanged` into `OnDependencyValue`; re-register on every apply.
  **Register-AND-consume trap:** the DoD includes a Runtime test proving a calc driver's
  deps actually flow (not just that the interface exists).
- Triggers: change-gate à la `VirtualTagActor` (no publish until all deps seen; dedupe) +
  timer via the dormant `TimerTriggerScheduler` revived inside the driver.
- Deploy gates in `DraftValidator`: `scriptId` existence in the draft generation; Tarjan
  cycle gate over calc→calc edges via the dormant `DependencyGraph` (cross-driver refs
  are terminal nodes). Compile deliberately NOT hard-gated (VT parity).
- Error semantics: Bad + last-known value once per Good→Bad transition + `ScriptLogEntry`
  on `script-logs`; recovery force-published; historized Bad = `BadInternalError`.
- Registration: `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` +
  `AddOtOpcUaDriverProbes` (probe = config-parse only, never throws); auto-created
  default `Engine` device; `DriverTypeNames.Calculation`.
- Tests per mini-design §8 (unit + the 2-node harness integration case).

**B2-WP8 — `DriverTypeNames` rewire + old-page retirement**

- Re-key the four drifted dispatch maps to T0-3 constants:
  `DriverEditRouter._componentMap` (fixes `TwinCat`→`TwinCAT`, `Focas`→`FOCAS`),
  `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `TagConfigValidator.cs`, and
  `ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs`. Add `Calculation`
  entries where applicable.
- Once `/raw` covers authoring end-to-end (this wave), retire the routed
  `/clusters/{id}/drivers` flow: `DriverTypePicker`, `DriverEditRouter`, the 8 page
  shells (their extracted form bodies live on in the modals). Nav + any deep links
  updated.

## Wave schedule

| Wave | Packages | Agents | Notes |
|---|---|---|---|
| 0 | Merge Track 0 outputs onto the branch | — | T0-1..T0-4 |
| A | WP1 ∥ WP2 | 2 | Service vs components — disjoint; WP2 stubs modals |
| B | WP3 ∥ WP4 ∥ WP5 | 3 | Disjoint: driver forms vs tag editors/grid vs CSV. All three consume WP1/WP2 |
| C | WP6 ∥ WP7 ∥ WP8 | 3 | WP6 gated on the universal browser; WP7 is mostly Runtime/driver-side (disjoint from UI packages); WP8 last-merged in the wave (it deletes pages others may still reference — coordinator merges WP8 after WP3/WP6) |

## Batch verification gate (live `/run` on docker-dev — authoring + probe only)

Rebuild **both** central nodes, `docker compose up -d`, bring up fixtures
(`lmxopcua-fix up modbus standard`, `lmxopcua-fix up s7 s7_1500`). Then on
`http://localhost:9200` (login disabled):

1. `/raw`: create Folder → Modbus driver (form body renders, enum fields correct) →
   Device with endpoint `10.100.0.35:5020` in `DeviceConfig` → **Test connect green**.
2. TagGroup → Manual entry: add 3 tags incl. one historized with a >255-char effective
   tagname → **deploy blocked by DraftValidator naming the tag**; shorten/override →
   deploy succeeds (POST `/api/deployments`, `X-Api-Key`).
3. CSV: export the device's tags, delete one, re-import the file → review grid shows the
   re-add, commit restores it. Import a file with a bad enum value → row-level error in
   the grid, no partial commit.
4. Browse (if WP6 landed): browse an AbCip/OpcUaClient device, multi-select leaves with
   "create matching tag-groups" → raw tags land under mirrored groups with correct
   DataType + address field.
5. Calculation: create a `Calculation` driver → author a calc tag whose script reads the
   Modbus tag's RawPath → deploy succeeds; author a 2-cycle (A reads B, B reads A) →
   **deploy blocked naming the cycle members**. (Computed values are NOT visible yet —
   Batch 4; the script-log page must stay silent, no crash loops in
   `docker compose logs`.)
6. Rename a driver → the UI warning seam fires for a historized tag beneath it.
7. Confirm the retired `/clusters/{id}/drivers` routes are gone from nav and 404/redirect
   cleanly.

Record each step's evidence in the PR.
