- 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.
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
TagConfigshapes, 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/: newRawFolder.cs,TagGroup.cs,UnsTagReference.cs; reshapeDriverInstance.cs(add nullableRawFolderId; dropNamespaceId),Device.cs(addDeviceConfigJSON; now universal — every driver ≥1 device),Tag.cs(requiredDeviceIdFK, nullableTagGroupId; dropEquipmentId+FolderPath; keepWriteIdempotent— dropping it regresses the R2 resilience fix),Equipment.cs(drop theDriverInstanceId/DeviceIdbinding). DeleteNamespace.cs,EquipmentImportBatch.cs(both classes), andEnums/NamespaceKind.cs; deleteNamespaceKindCompatibility+AllowedNamespaceKindsfromsrc/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs.OtOpcUaConfigDbContext.cs— new DbSets; all uniqueness/integrity config inline inOnModelCreatingper the design's rules: sibling-name unique indexes at every raw level (filtered indexes for nullableParentRawFolderId/RawFolderId/TagGroupId),OnDelete(Restrict)so raw-tag delete is blocked while aUnsTagReferencepoints at it and non-emptyRawFolder/TagGroupdeletes are blocked.- Migration strategy (decided): squash. Delete the existing
Migrations/folder and scaffold a singleV3Initialmigration. Port the hand-written stored-proc migrations'UpSQL (the*_StoredProcedures.cs/*_ExtendComputeGenerationDiffWith*.csfiles) intoV3Initialverbatim — audit them first: any proc referencing dropped columns/tables (NamespaceId,Tag.EquipmentId,Tag.FolderPath) must be rewritten to the new shape, andComputeGenerationDiffmust 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 indocker-dev/seed/entrypoint.shthat names tables) and everyscripts/smoke/seed-*.sqlto the new schema — each driver gets a folder (optional), a device row withDeviceConfig(endpoint moved out ofDriverConfig— 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/SchemaComplianceTestsupdated 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: removeFullName/ExplicitFullName(the blob-as-identity convention dies here); keep the alarm/historize/array parsing (TagAlarmIntent,ParseAlarm/ParseHistorize/ParseArray) — those remain realTagConfigconcerns. 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"; thedeviceHostAddresskey (AbCip/AbLegacy/TwinCAT/FOCAS) is dropped (the tag'sDeviceIdFK owns placement). UpdateOpcUaClientTagConfigModel.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; newRawPathsTests; deleteConfiguration.Tests/DraftValidatorGalaxyFullNameCorpusTests.csandCore.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 pureTagConfig JSON → definitionmapper invoked at deploy-time table build (no more "is this ref a blob?" heuristics). Rename toModbusTagDefinitionFactory(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.csreworked —DeviceConfigJSON now carries host/endpoint + per-device settings for all drivers. - The 4 multi-device drivers: remove the embedded
Devices[]list fromModbusDriverOptions.cs(),S7DriverOptions.cs(),AbCipDriverOptions.cs,AbLegacyDriverOptionsfile,TwinCATDriverOptions.cs,FocasDriverOptionsfile — (*grounding note: Modbus and S7 options also carryDeviceslists — treat all six options classes the same way:DriverConfigkeeps protocol/channel-level settings only; per-device settings move toDeviceConfig). Single-endpoint drivers (OpcUaClientEndpointUrl, Galaxy gateway address, S7 host if single) move their endpoint into the auto-created default device'sDeviceConfig. - Runtime spawning:
Runtime/Drivers/DriverInstanceActor.cs+ the artifact device-host map inRuntime/Drivers/DeploymentArtifact.cs— driver instantiation now mergesDriverConfig+ per-deviceDeviceConfigrows (this merged shape is also what Batch 2's browse modal passes asconfigJson— 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.csto the v3 shapes (nodeviceHostAddress, no PascalCaseFullName, 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;RevisionHashinputs updated.Runtime/Drivers/DriverHostActor.cs— the map keys change meaning, not shape:_nodeIdByDriverRef/_driverRefByNodeId/_alarmNodeIdByDriverRef/_driverRefByAlarmNodeIdnow carry RawPath as the ref string. Rename the tuple memberFullName→RawPathso 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 ahistorianTagnameoverride; 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.razoretc. — 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.cswill 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
dotnet build ZB.MOM.WW.OtOpcUa.slnxanddotnet test ZB.MOM.WW.OtOpcUa.slnxgreen (macOS-safe suites), plus the DB-backedConfiguration.Testsagainst10.100.0.35,14330.- Fresh-DB migration proof: point
OTOPCUA_CONFIG_CONNECTIONat a scratch DB,dotnet ef database update, then runSchemaComplianceTests. - 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 the10.100.0.35fixtures (lmxopcua-fix up modbus standardetc.); AdminUI loads (stubbed pages show their banners, nothing crashes). - Explicitly assert the dark address space: Client.CLI
browse -u opc.tcp://localhost:4840 -r -d 3shows hierarchy/no tag variables — and the PR description says so, so nobody files it as a bug.