# 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 = ////` — 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.