21 KiB
Driver-less Equipment Namespace — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
Goal: Let VirtualTag-only equipment reference no field driver — make Equipment.DriverInstanceId nullable, delete the misleading Modbus placeholder driver, and stop the per-deploy stub/exception noise, while preserving cluster-attribution correctness.
Architecture: The data path is unchanged (equipment values stream from the GalaxyMxGateway driver via the galaxy-mirror → VirtualTag indirection). Almost nothing reads Equipment.DriverInstanceId, so the change is: (1) make the column nullable (entity + one EF migration), (2) fix the single cluster-attribution site (DeploymentArtifact.BuildClusterSets) to anchor driver-less equipment on its UNS line's area cluster instead of its driver, (3) update the loader to stop creating the placeholder and NULL the FK, (4) live-verify on docker-dev.
Tech Stack: .NET 10, EF Core (MSSQL), Akka.NET, xUnit + Shouldly. Solution ZB.MOM.WW.OtOpcUa.slnx, Central Package Management. Branch feat/driverless-equipment-namespace (off master 446a456). Loader: Python (scadaproj/otopcua-uns-loader/otopcua_uns.py). Design: docs/plans/2026-06-08-driverless-equipment-namespace-design.md.
Sequencing: T1 (entity + migration) first — it's the schema foundation. T2 (BuildClusterSets) and T3 (validator test) build on T1; T4 (loader) is a different repo and runs anytime. T5 (live verify) needs T1+T2+T4.
Task 1: Make Equipment.DriverInstanceId nullable + EF migration
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 4 (different repo)
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs(line ~23) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor(~line 191 — null-guard the EF predicate) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor(FormModel + dropdown + save — full driver-less support) - Create:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/<timestamp>_NullableEquipmentDriverInstanceId.cs(generated bydotnet ef) - Auto-modify:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs(regenerated bydotnet ef)
AdminUI surface (found at build time — the nullable change breaks two .razor sites under TreatWarningsAsErrors):
TagEdit.razor:191:db.Equipment.Where(e => driverIds.Contains(e.DriverInstanceId))→ guarde.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId)(behavior-preserving; SQL already excludes NULL).EquipmentEdit.razor: full driver-less support —FormModel.DriverInstanceId→string?; add a "(none / driver-less)" option to the driver<select>; remove the mandatoryif (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }check (~line 209); on save (both the IsNewAdd~line 222 and the update ~line 245) writestring.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId.
Context: DriverInstanceId is currently public required string DriverInstanceId { get; set; }. The EF config OtOpcUaConfigDbContext.ConfigureEquipment has no .IsRequired() call and no FK relationship for it (it's a logical FK only) — so EF infers NOT NULL purely from the C# required string type. Changing the type to string? flips the column to nullable; no fluent-config change is needed. The index IX_Equipment_Driver is a plain non-unique index and stays valid on a nullable column.
Step 1 — Change the entity property. In Equipment.cs, change:
public required string DriverInstanceId { get; set; }
to:
public string? DriverInstanceId { get; set; }
(Drop the required modifier and make the type nullable. Update the property's XML-doc comment if it asserts non-null — note it is now optional: null = VirtualTag-only / driver-less equipment.)
Step 2 — Build to confirm no compile break from the nullability change.
Run: dotnet build ZB.MOM.WW.OtOpcUa.slnx
Expected: build succeeds. If any production code dereferences Equipment.DriverInstanceId with a non-null assumption (! or implicit), the nullable-reference analyzer will flag it under TreatWarningsAsErrors — the investigation found NONE in production code, so expect 0 new warnings. If one appears, STOP and report the file:line (it's an undocumented deref the design missed).
Step 3 — Author the migration. Confirm the EF tool is available (dotnet ef --version; if missing, dotnet tool restore or dotnet tool install --global dotnet-ef). Set the design-time connection env (no DB connection is made during migrations add — it diffs the model), then scaffold:
export OTOPCUA_CONFIG_CONNECTION="Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
dotnet ef migrations add NullableEquipmentDriverInstanceId --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration
Inspect the generated migration. It MUST be a single AlterColumn and its inverse — nothing else:
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId", table: "Equipment",
type: "nvarchar(64)", maxLength: 64, nullable: true,
oldClrType: typeof(string), oldType: "nvarchar(64)", oldMaxLength: 64);
// Down(): the same AlterColumn with nullable: false / oldNullable: true
If the migration contains anything beyond this AlterColumn (e.g. unrelated model drift), STOP and report — the model snapshot may be out of sync with master and that must be resolved first.
Step 4 — Build + run the Configuration test suite (behaviour-preserving).
Run: dotnet build ZB.MOM.WW.OtOpcUa.slnx then dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ (glob tests for the exact path if it differs).
Expected: green. Nothing asserts DriverInstanceId non-null, so all existing tests pass.
Step 5 — Commit.
git add -A && git commit -m "feat(config): make Equipment.DriverInstanceId nullable (driver-less equipment) + migration"
Acceptance: entity is string?; the new migration is a single AlterColumn(... nullable: true); the model snapshot drops .IsRequired() on DriverInstanceId; solution builds; Configuration tests green. Do NOT apply the migration to any DB here — that happens in Task 5.
Task 2: BuildClusterSets — attribute driver-less equipment via its UNS line's area cluster
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3 (different file), Task 4 (different repo)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs(BuildClusterSets, ~lines 271–297, and itsClusterSetsdoc ~260–267) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/...(find the existing DeploymentArtifact test file by glob, e.g.*DeploymentArtifact*Tests.csor*ParseComposition*/*ClusterScope*)
Context: Equipment carries no ClusterId. Today BuildClusterSets attributes equipment to a cluster via its driver:
// Equipment carries no ClusterId — include it when its DriverInstanceId is in-cluster.
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && di is not null && driverIds.Contains(di))
equipmentIds.Add(id!);
A null driver ⇒ equipment (and its VirtualTags) silently dropped in multi-cluster (ScopeTo) mode. The fix anchors driver-less equipment on its UNS placement: UnsArea carries ClusterId (collected into areaIds), and Equipment → UnsLine.UnsAreaId → UnsArea. So a driver-less equipment is in-cluster iff its line's area is in-cluster. Single-cluster (ClusterFilterMode.None) never calls BuildClusterSets, so this only affects multi-cluster — but it's a correctness fix worth a test.
Step 1 — Write the failing test. First READ the existing DeploymentArtifact/cluster-scope test file to learn the harness: how a deployment-artifact JSON blob is built (the helper that assembles DriverInstances/UnsAreas/UnsLines/Equipment/EquipmentVirtualTags arrays), and how ParseComposition(blob, scope) / BuildClusterSets results are asserted. Mirror that harness exactly. Add a test like:
Driverless_equipment_is_kept_when_its_line_area_is_in_cluster: blob with an in-clusterUnsArea(ClusterId = "C1"), aUnsLinein that area, anEquipmentwithDriverInstanceId: nullon that line, plus anEquipmentVirtualTagfor it. Call the cluster-scoped parse for cluster "C1". Assert the equipment node AND its VirtualTag are present in the result.Driverless_equipment_is_excluded_when_its_line_area_is_in_another_cluster: same but the area's ClusterId is "C2"; scope to "C1" → equipment + its vtag absent.- (Keep/confirm an existing driver-bound case still works — driver-bound equipment attribution must be unchanged.)
If the existing tests assert via ParseComposition(blob, ClusterScope), use that public entry; if they unit-test BuildClusterSets directly via an internal-visible hook, follow that. Do NOT invent a new harness.
Step 2 — Run, confirm fail. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ --filter "FullyQualifiedName~Driverless" → the new tests fail (driver-less equipment currently dropped).
Step 3 — Implement. In BuildClusterSets, before the Equipment loop, build a UnsLineId → UnsAreaId map from the artifact's UnsLines array; then attribute driver-less equipment by line-area:
// Map each UnsLine to its area so driver-less equipment can be cluster-attributed by its UNS placement.
var lineToArea = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array)
{
foreach (var el in lines.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null;
var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId))
lineToArea[lineId!] = areaId!;
}
}
// ... in the Equipment loop, replace the driver-only attribution with:
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var inClusterByDriver = di is not null && driverIds.Contains(di);
var inClusterByLine = di is null && lineId is not null
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
if (inClusterByDriver || inClusterByLine)
equipmentIds.Add(id!);
Update the // Equipment carries no ClusterId — … comment and the ClusterSets.EquipmentIds doc (~line 263) to state: driver-bound equipment is attributed by its driver's cluster; driver-less equipment by its UNS line's area cluster. Keep driverIds/areaIds collection unchanged. Note: areaIds is already populated (the CollectIdsWhereCluster(root, "UnsAreas", …) call) before the Equipment loop — confirm ordering so areaIds is filled first.
Step 4 — Run, confirm pass + the broader DeploymentArtifact/Runtime suite stays green:
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ --filter "FullyQualifiedName~DeploymentArtifact" (and the Driverless filter). Also dotnet build ZB.MOM.WW.OtOpcUa.slnx.
Step 5 — Commit.
git add -A && git commit -m "fix(deploy): cluster-attribute driver-less equipment via its UNS line area (BuildClusterSets)"
Acceptance: driver-less equipment + its VirtualTags are kept in ScopeTo mode when the line's area is in-cluster, excluded otherwise; driver-bound attribution unchanged; suite green.
Task 3: DraftValidator accepts a driver-less-equipment draft
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2 (different file), Task 4 (different repo)
Files:
- Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/...(find the existingDraftValidatortest file by glob, e.g.*DraftValidator*Tests.cs)
Context: No validator rule dereferences Equipment.DriverInstanceId or requires an Equipment namespace to have a driver. This task LOCKS that in with a regression test (no production change). It proves the design's core safety claim: a draft with driver-less equipment + an Equipment-kind namespace that has zero DriverInstance rows validates clean.
Step 1 — Write the test. READ the existing DraftValidator tests to learn how a DraftSnapshot is constructed in this suite (the builder/fixture for Equipment, Namespaces, DriverInstances, UnsAreas/Lines, ServerCluster/ClusterNode). Then add:
Validate_accepts_driverless_equipment_in_driverless_equipment_namespace: build a minimal valid draft with an Equipment-kind namespace that has zeroDriverInstancerows, and oneEquipmentwithDriverInstanceId = null(canonicalEQ-…id, valid Name/UnsLineId, with the UnsLine/UnsArea/cluster wiring the other rules need), plus its VirtualTag. AssertDraftValidator.Validate(snapshot)returns zero errors (result.Errors.ShouldBeEmpty()or the suite's idiom). Reuse whatever canonical-equipment helper the suite already has (the EquipmentId must satisfyValidateEquipmentIdDerivation="EQ-" + uuid.ToString("N")[:12].ToLowerInvariant()); copy that derivation from an existing passing test so the test isn't tripped by an unrelated rule.
Step 2 — Run, confirm pass. dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ --filter "FullyQualifiedName~DraftValidator" → green (this is a characterization test; it should pass immediately on top of Task 1's nullable entity). If it FAILS, that means a rule DOES implicitly require a driver — STOP and report which error code fired (the design would need revisiting).
Step 3 — Commit.
git add -A && git commit -m "test(config): DraftValidator accepts driver-less equipment + driverless equipment namespace"
Acceptance: the new test passes; existing DraftValidator tests stay green.
Task 4: Loader — stop creating the placeholder, NULL the equipment FK
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 3 (different repo)
Files:
- Modify:
/Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py(cmd_populate_equipment,cmd_clean, theEQ_DRIVERconstant + its comment ~lines 86–91)
Context: The loader invents a placeholder Modbus DriverInstance (nw-uns-modbus) and points all 40 equipment at it. With the column now nullable (Task 1), equipment should reference NO driver.
Step 1 — Edit cmd_populate_equipment. READ the function first. Then:
- Remove the placeholder
DriverInstanceINSERT (the... VALUES (NEWID(), %s, %s, %s, 'Northwind UNS placeholder', 'Modbus', 1, '{}')block, ~lines 275–278) and its preceding "Placeholder driver…" comment. - Equipment INSERT (~lines 296–299): drop
DriverInstanceIdfrom the column list and its value param entirely, so the row inserts with a NULL driver. E.g.:(Verify the exact column/param alignment against the current code — remove exactly thecur.execute( "INSERT INTO dbo.Equipment (EquipmentRowId, EquipmentId, EquipmentUuid, UnsLineId, " "Name, MachineCode, Manufacturer, Model, Enabled) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, 1)", (eq_id, eq_uuid, "nw-" + e["unsLineId"], e["name"], e["machineCode"], e.get("manufacturer"), e.get("model")))DriverInstanceIdcolumn and theEQ_DRIVERarg, keep everything else.) - In the teardown block at the top of
cmd_populate_equipment(the DELETEs), remove the now-deadDELETE FROM dbo.Tag WHERE DriverInstanceId=%s, (EQ_DRIVER,)andDELETE FROM dbo.DriverInstance WHERE DriverInstanceId=%s, (EQ_DRIVER,)lines (no such rows are created anymore). Keep the VirtualTag/Script/Equipment/UnsLine/UnsArea/Namespace deletes.
Step 2 — Edit cmd_clean the same way: remove the DELETE … Tag WHERE DriverInstanceId=%s and DELETE … DriverInstance WHERE DriverInstanceId=%s lines that reference EQ_DRIVER.
Step 3 — Retire the constant + fix the comment. Remove the EQ_DRIVER = "nw-uns-modbus" constant and the stale comment block (~lines 86–91) that explains the placeholder ("A placeholder non-Galaxy driver kept ONLY to satisfy 'an Equipment namespace has a driver' expectations…"). If any other reference to EQ_DRIVER remains, grep EQ_DRIVER across the file and remove/adjust each.
Step 4 — Smoke-check the script parses. python3 -c "import ast; ast.parse(open('/Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py').read())" → no SyntaxError. (Do NOT run populate against the DB here — that's Task 5, after the migration is applied.) Confirm no lingering EQ_DRIVER references: grep -n EQ_DRIVER /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py → no output.
Step 5 — Commit (in the scadaproj repo).
cd /Users/dohertj2/Desktop/scadaproj && git add otopcua-uns-loader/otopcua_uns.py \
&& git commit -m "loader: equipment is driver-less (drop Modbus placeholder, NULL DriverInstanceId)"
(Commit on scadaproj main — the loader lives there; do NOT push.)
Acceptance: no EQ_DRIVER references remain; the placeholder DriverInstance INSERT is gone; the Equipment INSERT omits DriverInstanceId; the script parses; teardown still cleans the overlay.
Task 5: Live docker-dev verification
Classification: standard Estimated implement time: ~6 min (+ migration/deploy/settle) Parallelizable with: none (needs T1, T2, T4)
Steps (no new code — the proof the wart is gone and nothing regressed):
- Build the docker-dev image off this branch (carries the nullable entity + BuildClusterSets fix):
cd docker-dev && docker compose build central-1. - Apply the migration to the docker-dev config DB. The DB is NOT auto-migrated. Run EF update against the live config DB (port 14330):
Confirm:
export OTOPCUA_CONFIG_CONNECTION="Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" dotnet ef database update --project src/Core/ZB.MOM.WW.OtOpcUa.ConfigurationApplied migration '<...>_NullableEquipmentDriverInstanceId'. Sanity-check the column is now nullable:docker exec otopcua-dev-sql-1 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -C -d OtOpcUa -Q "SELECT is_nullable FROM sys.columns WHERE object_id=OBJECT_ID('Equipment') AND name='DriverInstanceId';"→1. - Re-run the loader driver-less (clears + reloads the equipment overlay with NULL driver, no placeholder):
cd /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader && .venv/bin/python otopcua_uns.py populate-equipment(use the same invocation the project README/memory documents). Verify in SQL:SELECT COUNT(*) FROM Equipment WHERE DriverInstanceId IS NULL= 40;SELECT COUNT(*) FROM DriverInstance WHERE DriverInstanceId='nw-uns-modbus'= 0. - Recreate the admin nodes on the new image (sites untouched):
cd docker-dev && docker compose up -d --no-deps --force-recreate central-1 central-2. Wait for:9200to answer (302). - Redeploy headless:
curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'→ expect 202 Accepted. - Verify values still flow:
cd /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader && .venv/bin/python otopcua_uns.py verify-equipment --require-good 396 --wait→VERIFY-EQUIPMENT: PASS, 396 Good. - The wart is gone — confirm the Modbus stub/exception no longer appears:
docker logs otopcua-dev-central-1-1 2>&1 | grep -iE "nw-uns-modbus|missing required Host|spawned Modbus driver"→ no output (previously this showedfactory for Modbus threw on nw-uns-modbus+spawned Modbus driver nw-uns-modbus (stub=True)).
Acceptance: migration applied (column nullable); 40 equipment rows have NULL driver and the nw-uns-modbus DriverInstance is gone; deploy 202 Accepted; verify-equipment PASS (396 Good); central-1 log clean of the Modbus stub/exception. This task changes the running docker-dev stack (the user's active env) — coordinate: recreate only the admin nodes; don't disrupt the site nodes.
After all tasks
Run the affected suites (Configuration, Runtime DeploymentArtifact) + build the solution, then use superpowers-extended-cc:finishing-a-development-branch: verify green, present merge/PR/keep/discard for feat/driverless-equipment-namespace (OtOpcUa) and the scadaproj loader commit. Merge/push only on the user's explicit go (the user manages this repo's integration).