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