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