# v3 implementation plan — master orchestration

**Date:** 2026-07-15
**Design:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` (all open items resolved; required reading for every agent)
**Companion designs:** `2026-07-15-calculation-driver-mini-design.md` (Batch 2), `2026-07-15-universal-discovery-browser-design.md` (pre-v3 dependency)
**Batch plans:** `2026-07-15-v3-batch1-schema-identity-plan.md` · `2026-07-15-v3-batch2-raw-ui-calculation-plan.md` · `2026-07-15-v3-batch3-uns-rework-plan.md` · `2026-07-15-v3-batch4-address-space-plan.md`
**Executors:** Opus agents. This document is the coordinator's contract: batch order, parallel lanes, merge discipline, and the non-negotiable verification gates.

## 1. Shape of the work

Four **sequential batches** (each = one feature branch + one PR to master, merged only after
its live gate passes), plus one **batch-independent track (Track 0)** that can run in
parallel with Batch 1 because it touches nothing Batch 1 touches.

```
Track 0 (parallel with Batch 1): ContextMenu component · RFC-4180 CSV parser ·
                                 DriverType constants · Calculation evaluator core
        │
Batch 1 (schema + RawPath identity)  ──►  Batch 2 (/raw UI + Calculation driver)
        ──►  Batch 3 (UNS reference-only + {{equip}})  ──►  Batch 4 (dual-namespace
        address space)  =  v3.0
```

**Precondition:** the universal `DiscoveryDriverBrowser`
(`2026-07-15-universal-discovery-browser-design.md`) is a **pre-v3 dependency that is NOT
in the tree yet** (verified 2026-07-15: no `DiscoveryDriverBrowser` exists;
`ITagDiscovery` has no `SupportsOnlineDiscovery` member — the browser design adds it).
Only Batch 2 work package **B2-WP6 (browse re-target)** is blocked on it. Everything else
proceeds; if the browser slips, B2-WP6 detaches into a follow-up PR and the Batch 2 gate
runs without the browse-commit leg.

**Why the batches cannot overlap:** each batch's live gate deploys the previous batch's
schema/UI to docker-dev. Batch 2 authors against Batch 1's schema; Batch 3 references
Batch 2's raw tags; Batch 4 lights up what 1–3 authored. Parallelism lives *inside*
batches (see each batch plan's wave schedule), not between them.

## 2. Agent execution model

- **One coordinator agent per batch.** It reads the design + the batch plan, spawns
  implementation agents per work package, owns the batch feature branch, and is the only
  agent that merges.
- **Implementation agents run in isolated git worktrees** (`isolation: worktree` /
  `EnterWorktree`). Hard rule from project history: parallel implementers on a shared
  checkout race the git index and corrupt each other's staging — never share a tree.
- **Contract-first fan-out.** When a wave shares new types (e.g. the `RawPaths` helper,
  the reshaped entities), the coordinator lands a small **contracts commit** on the batch
  branch first; wave agents branch from it. No agent invents a shared type mid-wave.
- **File-ownership discipline.** The wave tables in each batch plan assign files to
  exactly one work package per wave. If an agent finds it must touch a file owned by
  another package, it stops and reports — the coordinator re-slices rather than letting
  two agents edit one file.
- **Merge order** is the wave order. After each wave merges, the coordinator runs
  `dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test ZB.MOM.WW.OtOpcUa.slnx` before
  starting the next wave. A red build blocks the next wave; the coordinator fixes or
  reverts, never "fixes forward" into a parallel wave.
- **Review checkpoint per wave:** a code-reviewer agent reviews the wave diff against the
  batch plan before merge. Findings are fixed in the worktree, not post-merge.
- **Each batch ends with the live `/run` gate executed by an agent with browser tools**
  (the docker-dev AdminUI has `Security__Auth__DisableLogin: "true"` — no sign-in needed;
  drive `http://localhost:9200` directly). **Live gates are non-negotiable**: this project
  has repeatedly shipped prod-inert code that 250+ unit tests and three reviews missed
  (the `DeferredAddressSpaceSink` forwarding trap, the dormant `GatewayTagProvisioner`).
  A batch is not done until its live-gate checklist passes.

## 3. Track 0 — batch-independent work (start immediately, parallel with Batch 1)

Four packages with zero overlap with Batch 1's files. Each is a separate worktree +
separate small PR (or merged onto the Batch 2 branch when it opens).

| ID | Package | New files (nothing existing is touched) | Verification |
|---|---|---|---|
| T0-1 | **`ContextMenu` Blazor component** — reusable `oncontextmenu` menu with keyboard/touch fallback (explicit "⋯" affordance). No right-click menu exists anywhere in the Blazor app today (the only `ContextMenu` is in the Avalonia desktop client — do not confuse them). | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor` (+ `.razor.css`), `ContextMenuItem.cs` | Demo host page under a dev-only route; manual live check on docker-dev (AdminUI has **no bUnit** — Razor bugs must be live-verified) |
| T0-2 | **RFC-4180 CSV parser** — quoted fields, embedded commas/quotes/newlines, CRLF/LF; plus a writer that quotes on demand. Pure, no I/O. No CSV code exists in the repo today (verified) — greenfield. | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs`, `CsvWriter.cs`; tests in `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/` | Unit suite incl. RFC 4180 edge corpus (quoted quote, trailing newline, empty field vs absent field) |
| T0-3 | **`DriverTypeNames` constants class** — single source of truth for driver-type strings, authority = the factories' `DriverTypeName` (`TwinCAT`, `FOCAS`, `GalaxyMxGateway`). Consumed later by the four drifted maps (`DriverEditRouter._componentMap` currently keys `TwinCat`/`Focas`; `TagConfigEditorMap`; `TagConfigValidator`; `EquipmentTagConfigInspector` in **ControlPlane**, not AdminUI). T0-3 only *adds* the constants + a reflection test asserting they match every registered factory; the four consumers are rewired in Batch 2 (B2-WP8). | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`; test in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (or Commons.Tests if no such project) | Reflection test: every `DriverTypeName` exposed by `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` registrations has a matching constant, and vice versa |
| T0-4 | **Calculation evaluator core** — `CalculationEvaluator` built like `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `CompiledScriptCache` + `TimedScriptEvaluator` (2 s) + `VirtualTagContext`, single-tag mode (`ctx.SetVirtualTag` dropped + logged). Pure engine + unit tests only; the driver shell that hosts it is Batch 2 (B2-WP7). See mini-design §4. | New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` (evaluator + tests only at this stage) | Parity unit tests vs the VT evaluator: passthrough fast-path, timeout, sandbox violation, SetVirtualTag drop |

## 4. Batch summaries + gates (detail in the batch plans)

| Batch | Branch | Content | Gate (summary — full recipe in the batch plan) |
|---|---|---|---|
| 1 | `v3/batch1-schema-identity` | Greenfield EF schema (RawFolder/Device-universal/TagGroup/Tag-raw-only/UnsTagReference; Namespace + EquipmentImportBatch retired); `RawPaths` identity helper; `TagConfigIntent` sheds FullName; all 8 drivers' resolvers keyed by RawPath (blob-fallback parsers retired); endpoint→`DeviceConfig` move; composer/artifact/DraftValidator/UnsTreeService rewired; seeds + golden corpus rewritten. **Address space intentionally dark.** | Full solution build + test green (incl. rewritten corpus); `V3Initial` migration applies to a fresh DB; docker-dev re-seeds and both nodes boot with drivers Connected (values not yet exposed — that's Batch 4) |
| 2 | `v3/batch2-raw-ui` | `/raw` project tree (lazy loading + T0-1 menus), driver config/device modals (typed pages refactored to embeddable bodies), tag manual entry + CSV import/export (T0-2 + decided column dictionaries), browse re-target (gated on universal browser), `Calculation` driver (T0-4 + mini-design), `DriverTypeNames` rewire | Live `/run` on `:9200`: author folder→driver→device→tags in-tree; Test-connect green vs a fixture; CSV round-trip; browse-commit raw tags (if browser landed); author + deploy a calc tag. Proves authoring + probe only — no live values until Batch 4 |
| 3 | `v3/batch3-uns-rework` | Equipment Tags tab → reference list + cluster-scoped multi-select raw picker; effective-name uniqueness (authoring + DraftValidator); `{{equip}}` reference-relative resolution + unresolved-ref deploy error; rename-warning substring scan | Live `/run`: reference raw tags into an equipment; display-name override shows; deploy gate rejects a reference collision and an unresolved `{{equip}}` ref |
| 4 | `v3/batch4-address-space` | Dual namespaces, RawPath NodeIds, UNS fan-out registration, raw-only runtime binding, write routing via either NodeId, alarm multi-notifier (`AddNotifier` pattern + teardown symmetry), historian dual-registration, sink namespace discriminator + `DeferredAddressSpaceSink` forwarding + reflection-test extension | Live `/run` + Client.CLI: browse both namespaces; read/subscribe/write through both NodeIds; HistoryRead via both; native alarm visible at raw + equipment notifiers with exactly one Server-object copy; `{{equip}}` script computes; failed-write revert still works. **= v3.0** |

## 5. Global conventions (binding for every agent)

- .NET 10, C#, xUnit + **Shouldly**, Serilog. Match surrounding code style; comments only
  for constraints the code can't show.
- JSON config keys are **camelCase**; enum values serialize as **string names**
  (`JsonStringEnumConverter`). Systemic past bug: AdminUI pages serialized enums
  numerically while DTOs were string-typed → authored configs faulted drivers. Every new
  serialization site gets the converter, and every new editor gets a round-trip test.
- Editors/models **preserve unknown JSON keys** (the established `<Driver>TagConfigModel`
  `FromJson`/`ToJson` contract). .NET 10 `JsonNode.ToJsonString` gotchas are documented in
  the existing models — copy the Modbus template.
- **Do not enable** `CentralPackageTransitivePinningEnabled` (breaks the Roslyn build via
  the 5.0.0/4.12.0 split). Transitive CVE fixes = surgical direct `PackageReference` at a
  common-ancestor project.
- EF migrations via `dotnet ef` with `DesignTimeDbContextFactory` (reads
  `OTOPCUA_CONFIG_CONNECTION`, defaults to `10.100.0.35,14330`). All model config lives
  inline in `OtOpcUaConfigDbContext.OnModelCreating` — keep it there (no
  `IEntityTypeConfiguration` files exist; don't introduce the pattern mid-stream).
- Blazor: string component parameters need the `@` prefix when passing C# expressions
  (the F15 gotcha that bit Global UNS); no bUnit exists — **live-verify every Razor
  change** on docker-dev.
- Name collision: Runtime already owns
  `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs` — any
  browser-side capturing builder must use a different name or namespace-qualify.

## 6. Global gotchas (project scar tissue — read before coding)

1. **The forwarding trap (highest-severity recurring bug):** any new or changed method on
   `IOpcUaAddressSpaceSink` / `ISurgicalAddressSpaceSink`
   (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/`) **must** be forwarded through
   `DeferredAddressSpaceSink` and covered by
   `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs`.
   A non-forwarded method compiles, passes unit tests, and is silently dead on driver
   hosts in production. Same family: registering a service in DI without passing it into
   its consumer (the dormant-`GatewayTagProvisioner` bug) — every "register X" step needs
   a "consume X" assertion.
2. **`OnWriteValue` runs under the node-manager lock** — treat driver write dispatch as
   fire-and-forget (`Tell`, never blocking ask) exactly as `OnEquipmentTagWrite` does
   today (`OtOpcUaNodeManager.cs:1451`).
3. **Resolve `DriverHostActor` lazily** from consumers wired at startup — the actor
   spawns after DI graph construction.
4. **docker-dev `:9200` round-robins central-1/central-2** behind Traefik — rebuild and
   restart **both** (`docker compose build central-1 central-2 && docker compose up -d`)
   before trusting any AdminUI verification; kind-changing config edits need
   deploy-THEN-recreate.
5. Historian provisioning: `StorageRateMs` must be **> 0** (proto default 0 throws
   gateway-side and silently fails all provisioning — PR #439).
6. Client.CLI `write` **reads first** — you cannot write a node whose read is Bad. To
   force a protocol write failure on the rig, use the modbus fixture's
   `exception_injector` (FC06 reject rule on a seeded Good address).
7. Data-plane role tests need `Security:Ldap:GroupToRole` in appsettings; LDAP is the
   shared GLAuth `10.100.0.35:3893` (all test users password `password`).
8. Deploy API: `POST http://localhost:9200/api/deployments` with `X-Api-Key` from
   `Security:DeployApiKey` (self-disables 503 when unset). Direct SQL edits against the
   config DB need `SET QUOTED_IDENTIFIER ON`.
9. Driver fixtures live on the Docker host `10.100.0.35`, controlled via
   `lmxopcua-fix ls|up|down|sync` from this VM (`docker -H ssh://` does NOT work).
10. Native-alarm routing keys on **`ConditionId`** (the authored ref), never
    `SourceNodeId` — under v3 the ConditionId becomes the RawPath.

## 7. Deliverable checklist (coordinator ticks per batch)

- [ ] Batch branch opened from up-to-date master; contracts commit landed before fan-out.
- [ ] Every wave: worktree-isolated agents, file-ownership respected, reviewer pass, green
      build+test after merge.
- [ ] Batch-specific unit/integration suites listed in the batch plan all green, including
      the DB-backed suites (`Configuration.Tests` schema-compliance against
      `10.100.0.35,14330`) and the 2-node harness
      (`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`, default in-memory mode).
- [ ] Live `/run` gate checklist from the batch plan executed and evidence recorded in the
      PR description (what was clicked/deployed/observed).
- [ ] Docs touched by the batch updated in the same PR (`docs/Configuration.md`,
      `docs/Uns.md`, `docs/ScriptEditor.md`, `docs/Historian.md`, `CLAUDE.md` sections
      that describe replaced behavior).
- [ ] Cross-repo: after Batch 4 merges, update the ScadaBridge coordination note + the
      umbrella index `../scadaproj/CLAUDE.md` OtOpcUa entry (the design's cutover plan
      step 4).
