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

15 KiB

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 FullNameRawPath 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.