cf03ca279d
- v3 design finalized: all 7 open items resolved (live historian probe:
255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
reconciled with the pre-v3 universal discovery browser (its new §11
v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
files/symbols: master plan (batch DAG, worktree-isolated parallel
waves, contract-first fan-out, global gotcha list) + one plan per
batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
each with per-wave file ownership and a mandatory docker-dev live gate.
163 lines
14 KiB
Markdown
163 lines
14 KiB
Markdown
# 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).
|