Files
lmxopcua/docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md
T
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- 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.
2026-07-15 16:50:30 -04:00

229 lines
15 KiB
Markdown

# v3 Batch 1 — greenfield schema + RawPath identity rewiring
**Branch:** `v3/batch1-schema-identity`
**Design authority:** `2026-07-15-raw-uns-two-subtree-v3-design.md` — §"v3 identity contract (RawPath)", §"Data model", §"Build plan" row 1.
**Master plan:** `2026-07-15-v3-implementation-plan.md` (conventions + gotchas are binding).
**End state:** the config schema and every identity seam speak RawPath; the solution builds and all tests pass; the address space composes hierarchy-only/empty (**deliberately dark until Batch 4**); docker-dev re-seeds and boots.
## Scope guard
- NO AdminUI feature work (Batch 2). AdminUI changes here are limited to *keeping it
compiling* against the new entity shapes (pages that authored the old shapes may be
temporarily stubbed with an "unavailable until Batch 2" banner — record each stub in the
PR description; Batch 2 owns their replacement).
- NO OPC UA node materialization changes beyond making the composer emit an empty/
hierarchy-only composition (Batch 4 owns the dual namespace).
- Greenfield means greenfield: no data-migration code, no back-compat shims for old
`TagConfig` shapes, no blob-fallback retention "just in case."
## The RawPath contract (what every package codes against)
`RawPath = <Folder/…>/<DriverName>/<DeviceName>/<TagGroup/…>/<TagName>` — cluster-scoped,
slash-separated, built ONLY by the new `RawPaths` helper (never string-concatenated ad
hoc). Segments forbid `/` and leading/trailing whitespace (validated at authoring).
Case-sensitive ordinal comparison everywhere. It keys: driver wire-refs, the
`DriverHostActor` fan-out/write maps, historian default tagname + mux refs, native-alarm
`ConditionId`, `ctx.GetTag` paths, and (Batch 4) `ns=Raw` NodeIds.
## Work packages
### Wave A — foundations (2 agents, parallel, disjoint files)
**B1-WP1 — Greenfield EF model + `V3Initial` migration + seeds**
Files (all under `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/` unless noted):
- `Entities/`: new `RawFolder.cs`, `TagGroup.cs`, `UnsTagReference.cs`; reshape
`DriverInstance.cs` (add nullable `RawFolderId`; **drop `NamespaceId`**), `Device.cs`
(add `DeviceConfig` JSON; now universal — every driver ≥1 device), `Tag.cs`
(required `DeviceId` FK, nullable `TagGroupId`; **drop `EquipmentId` + `FolderPath`**;
**keep `WriteIdempotent`** — dropping it regresses the R2 resilience fix),
`Equipment.cs` (**drop the `DriverInstanceId`/`DeviceId` binding**). Delete
`Namespace.cs`, `EquipmentImportBatch.cs` (both classes), and
`Enums/NamespaceKind.cs`; delete `NamespaceKindCompatibility` + `AllowedNamespaceKinds`
from `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`.
- `OtOpcUaConfigDbContext.cs` — new DbSets; **all** uniqueness/integrity config inline in
`OnModelCreating` per the design's rules: sibling-name unique indexes at every raw
level (filtered indexes for nullable `ParentRawFolderId`/`RawFolderId`/`TagGroupId`),
`OnDelete(Restrict)` so raw-tag delete is blocked while a `UnsTagReference` points at
it and non-empty `RawFolder`/`TagGroup` deletes are blocked.
- **Migration strategy (decided): squash.** Delete the existing `Migrations/` folder and
scaffold a single `V3Initial` migration. Port the hand-written stored-proc migrations'
`Up` SQL (the `*_StoredProcedures.cs` / `*_ExtendComputeGenerationDiffWith*.cs` files)
into `V3Initial` verbatim — audit them first: any proc referencing dropped
columns/tables (`NamespaceId`, `Tag.EquipmentId`, `Tag.FolderPath`) must be rewritten
to the new shape, and `ComputeGenerationDiff` must diff the new tables
(`RawFolder`/`Device`/`TagGroup`/`UnsTagReference`). Greenfield = no production DB to
upgrade, so squashing is safe; the fixture DBs are dropped/recreated.
- Seeds: rewrite `docker-dev/seed/seed-clusters.sql` (+ anything in
`docker-dev/seed/entrypoint.sh` that names tables) and every `scripts/smoke/seed-*.sql`
to the new schema — each driver gets a folder (optional), a device row with
`DeviceConfig` (endpoint moved out of `DriverConfig` — coordinate the JSON shape with
B1-WP5's contracts commit), and raw tags with RawPath-consistent names.
- Tests: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/`
`SchemaComplianceFixture`/`SchemaComplianceTests` updated to the new schema; new unit
tests for every integrity rule (delete-while-referenced blocked, non-empty folder/group
delete blocked, default-device-with-tags delete blocked, sibling-name uniqueness per
level, `/`-in-name rejected).
DoD: migration applies to a fresh DB (`dotnet ef database update` via
`DesignTimeDbContextFactory`); Configuration project + tests compile and pass (other
projects may be red until Wave B/C — that's expected mid-batch; the *batch branch* is
only merged green).
**B1-WP2 — `RawPaths` helper + `TagConfigIntent` successor + key normalization**
Files:
- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — pure static: `Build(...)`
from segment lists, `Validate(segment)` (no `/`, no lead/trail whitespace, non-empty),
`Combine`, `TryParent`. This is the contracts-commit centerpiece — land it first.
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs` — sheds the
FullName-derivation role: remove `FullName`/`ExplicitFullName` (the blob-as-identity
convention dies here); **keep** the alarm/historize/array parsing
(`TagAlarmIntent`, `ParseAlarm`/`ParseHistorize`/`ParseArray`) — those remain real
`TagConfig` concerns. Callers to sweep (grounded list): `DraftValidator` (Galaxy
ExplicitFullName rule → deleted; replaced by B1-WP6 rules),
`Core/OpcUa/EquipmentNodeWalker.cs`, `OpcUaServer/AddressSpaceComposer.cs` +
`AddressSpaceChangeClassifier.cs`, `AdminUI/ScriptAnalysis/IScriptTagCatalog.cs`,
`AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs`,
`Runtime/Drivers/DeploymentArtifact.cs`.
- Key normalization (greenfield, decided in the design): OpcUaClient's PascalCase
`"FullName"``"nodeId"`; Galaxy's → `"attributeRef"`; the `deviceHostAddress` key
(AbCip/AbLegacy/TwinCAT/FOCAS) is **dropped** (the tag's `DeviceId` FK owns placement).
Update `OpcUaClientTagConfigModel.cs` (AdminUI) and the two browsers' commit paths
(`Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs`,
`Driver.Galaxy.Browser/GalaxyBrowseSession.cs`) to emit the new keys.
- Tests: rewrite `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs`;
new `RawPathsTests`; delete
`Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs` and
`Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs` (superseded — B1-WP6
replaces them with RawPath-shaped corpus tests).
### Wave B — per-driver resolver rework (up to 9 agents, parallel, one per driver + exemplar-first)
**B1-WP3 — `EquipmentTagRefResolver` re-keying + blob-fallback retirement**
Shared seam first (the wave's contracts commit, done by the coordinator or the exemplar
agent): `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs` — the
`_byName` branch becomes the primary and only path: each deploy hands the driver its
authored raw tags keyed by RawPath (ref string) → `TagConfig`-derived definition. The
`_cache.GetOrAdd(fullReference, _parseRef)` blob-parse fallback is **deleted**; a miss is
a miss (`TryResolve` false → driver logs + Bad quality), never a parse attempt.
Then **exemplar: Modbus** (one agent, reviewed before fan-out so the pattern is fixed):
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs`
becomes a pure `TagConfig JSON → definition` mapper invoked at deploy-time table build
(no more "is this ref a blob?" heuristics). Rename to `ModbusTagDefinitionFactory` (or
similar) to make the retirement visible.
- Driver + tests updated to resolve by RawPath.
Then **fan-out, one agent per driver** (disjoint project directories — fully parallel):
S7 (`S7EquipmentTagParser.cs` — the leading-`{` blob convention dies), AbCip, AbLegacy,
TwinCAT, FOCAS (same shape as exemplar); **OpcUaClient** (no static parser — seam is
`Driver.OpcUaClient.Contracts/NamespaceMap.cs` + `OpcUaClientDriver.cs`; the definition's
address now reads `TagConfig.nodeId`); **Galaxy** (seam is
`Driver.Galaxy/Browse/GalaxyDiscoverer.cs` + `Browse/AlarmRefBuilder.cs`; address reads
`TagConfig.attributeRef`; note `AlarmRefBuilder` feeds `ConditionId` — under v3 that is
the RawPath, keep both directions consistent).
Each driver agent's DoD: driver project + its unit tests green with refs = RawPaths and
definitions sourced from the authored-tag table; zero references to
`TagConfigIntent.FullName` remain in the driver.
**B1-WP5 — endpoint → `DeviceConfig` move (runs parallel with WP3; owns different files)**
- Contracts: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs` reworked —
`DeviceConfig` JSON now carries host/endpoint + per-device settings for **all** drivers.
- The 4 multi-device drivers: remove the embedded `Devices[]` list from
`ModbusDriverOptions.cs`(*), `S7DriverOptions.cs`(*), `AbCipDriverOptions.cs`,
`AbLegacyDriverOptions` file, `TwinCATDriverOptions.cs`, `FocasDriverOptions` file —
(*grounding note: Modbus and S7 options also carry `Devices` lists — treat all six
options classes the same way: `DriverConfig` keeps protocol/channel-level settings only;
per-device settings move to `DeviceConfig`). Single-endpoint drivers (OpcUaClient
`EndpointUrl`, Galaxy gateway address, S7 host if single) move their endpoint into the
auto-created default device's `DeviceConfig`.
- Runtime spawning: `Runtime/Drivers/DriverInstanceActor.cs` + the artifact device-host
map in `Runtime/Drivers/DeploymentArtifact.cs` — driver instantiation now merges
`DriverConfig` + per-device `DeviceConfig` rows (this merged shape is also what Batch
2's browse modal passes as `configJson` — get the merge helper into Commons so both
reuse it: `DriverDeviceConfigMerger`).
- Golden corpus: rewrite
`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs` +
`TagConfigCorpusParityTests.cs` to the v3 shapes (no `deviceHostAddress`, no
PascalCase `FullName`, per-device configs separate).
Coordination rule: WP5 and WP3 both touch driver projects. Split ownership per file:
WP3 owns `*EquipmentTagParser.cs`/resolver/driver read-path files; WP5 owns
`*DriverOptions.cs`/factory/spawn-path files. Where one file carries both (a driver
factory that parses options AND builds the tag table), WP5 waits for that driver's WP3
agent to merge first — the coordinator sequences per-driver.
### Wave C — pipeline integration (2 agents, then 1 integrator)
**B1-WP4 — composer / artifact / actors / validator / services rewiring**
- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceChangeClassifier.cs`) — composes
from the new schema; emits a hierarchy-only/empty composition (folders exist as data;
**no variable nodes materialize** — Batch 4). Keep the alarm-intent parse (it flows to
the artifact) but drop equipment-tag NodeId composition.
- `Runtime/Drivers/DeploymentArtifact.cs` — flatten the new shape: per-driver authored
raw tags keyed by RawPath (feeding the WP3 resolvers), device rows with merged config
(WP5), historized set keyed by RawPath (effective historian tagname = override else
RawPath).
- `ControlPlane/AdminOperations/ConfigComposer.cs` (`SnapshotAndFlattenAsync`) — snapshot
the new tables into the sealed blob; `RevisionHash` inputs updated.
- `Runtime/Drivers/DriverHostActor.cs` — the map keys change meaning, not shape:
`_nodeIdByDriverRef` / `_driverRefByNodeId` / `_alarmNodeIdByDriverRef` /
`_driverRefByAlarmNodeId` now carry RawPath as the ref string. Rename the tuple member
`FullName``RawPath` so no stale semantics survive. NodeId sets stay empty until
Batch 4 (the maps still exist; mux forwarding still runs).
- `Configuration/Validation/DraftValidator.cs` + `DraftSnapshot(Factory)` — new rules:
raw-name charset (no `/`, no lead/trail whitespace) at every level; **historized-tag
effective tagname ≤ 255 chars** (live-verified AVEVA limit) → deploy error prompting a
`historianTagname` override; retired rules deleted (Galaxy ExplicitFullName, equipment
NodeId collision in its old form, namespace-binding checks).
- `AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs`, `Components/Pages/Uns/*`,
`Components/Shared/Uns/TagModal.razor` etc. — compile-only pass: the UNS tree keeps
Area/Line/Equipment; the Tags tab and TagModal driver-binding paths are stubbed behind
an "unavailable until v3 Batch 2/3" banner (list every stub in the PR).
- `ControlPlane/AdminOperations/AdminOperationsActor.cs` — per-tag TagConfig validation
loop updated (no FullName checks; alarm/historize checks retained).
**B1-WP6 — test-suite + harness sweep**
- Rewrite/replace the corpus tests deleted in WP2 with RawPath equivalents (resolution
per driver, artifact parity: `DeploymentArtifact*ParityTests.cs`).
- `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActor*Tests.cs`,
`DriverInstanceActor*Tests.cs`, `VirtualTags/*Tests.cs` — re-key refs to RawPaths.
- 2-node harness `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/` — seed shapes
updated (`EquipmentNamespaceMaterializationTests.cs` will assert the *dark* address
space this batch: hierarchy nodes only / no variables; mark the full assertion as
Batch-4-pending with a skip-reason constant, don't delete it).
- `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/` — ConfigComposer/deploy-path
shapes.
## Wave schedule + parallelization
| Wave | Packages | Agents | Parallel-safe because |
|---|---|---|---|
| A | WP1 ∥ WP2 | 2 | Disjoint: Configuration project vs Commons/driver-editor files. Coordinator lands `RawPaths.cs` + reshaped-entity contracts commit first |
| B | WP3 exemplar (Modbus) → review → WP3 fan-out (7 drivers) ∥ WP5 | up to 8 | Per-driver project directories are disjoint; WP3/WP5 per-file ownership rule above |
| C | WP4 → WP6 | 2 sequential (WP6 can start its mechanical re-keying while WP4 finishes, in a separate worktree, rebasing before merge) | WP4 is the single integration point — one agent only |
All agents in worktrees; merge order A → B → C; coordinator builds+tests the batch branch
after every merge.
## Batch verification gate
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` and `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green
(macOS-safe suites), plus the DB-backed `Configuration.Tests` against
`10.100.0.35,14330`.
2. Fresh-DB migration proof: point `OTOPCUA_CONFIG_CONNECTION` at a scratch DB,
`dotnet ef database update`, then run `SchemaComplianceTests`.
3. docker-dev: rebuild the migrator image + **both** central nodes, re-seed
(`docker-dev/seed/`), `docker compose up -d`; both nodes reach Running; seeded drivers
show Connected against the `10.100.0.35` fixtures (`lmxopcua-fix up modbus standard`
etc.); AdminUI loads (stubbed pages show their banners, nothing crashes).
4. Explicitly assert the dark address space: Client.CLI
`browse -u opc.tcp://localhost:4840 -r -d 3` shows hierarchy/no tag variables — and
the PR description says so, so nobody files it as a bug.