From 68a0f759f0854134acee003ffcc907dbb3a23ac6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 09:40:03 -0400 Subject: [PATCH] docs(plans): Phase 0+1 implementation plan for the still-pending backlog 12 tasks (0 branch; 1-3 Phase 0 hygiene; 4-5 H1 changed-only-deploy fix; 6-9 H5 vtag Historize threading + IHistoryWriter seam; 10 docs; 11 verify). Conservative rebuild-on-change; no EF migration (Historize column + artifact already carry it); durable AVEVA sink flagged infra-gated. --- .../2026-06-15-stillpending-phase-0-1.md | 451 ++++++++++++++++++ ...06-15-stillpending-phase-0-1.md.tasks.json | 18 + 2 files changed, 469 insertions(+) create mode 100644 docs/plans/2026-06-15-stillpending-phase-0-1.md create mode 100644 docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json diff --git a/docs/plans/2026-06-15-stillpending-phase-0-1.md b/docs/plans/2026-06-15-stillpending-phase-0-1.md new file mode 100644 index 00000000..2f135ff7 --- /dev/null +++ b/docs/plans/2026-06-15-stillpending-phase-0-1.md @@ -0,0 +1,451 @@ +# Still-Pending Backlog — Phase 0 + Phase 1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Land Phase 0 (no-risk hygiene) and Phase 1 (the two silent-deploy bugs H1 + H5) of the approved backlog roadmap `docs/plans/2026-06-15-stillpending-backlog-design.md`. + +**Architecture:** H1 is the *conservative* fix — `Phase7Applier.needsRebuild` includes the already-computed `Changed*` counts, and `VirtualTagHostActor` stop+respawns children whose plan changed, so any rename/severity/dataType/Writable/expression edit actually takes effect (a rebuild repopulates idempotently from the persisted artifact). H5 threads the existing `VirtualTag.Historize` flag through the equipment-namespace plan (composer **and** artifact-decode, byte-parity) and has `VirtualTagHostActor` invoke an injected `IHistoryWriter` (default `NullHistoryWriter`) on each historized result instead of silently dropping it. **No EF migration** — `Historize` already exists as a column and is already serialized into the artifact by `ConfigComposer`. + +**Tech Stack:** C# / .NET 10, Akka.NET (TestKit), xUnit + Shouldly, OPC UA Foundation stack. + +**Key findings that shape this plan (verified during planning):** +- `Phase7Applier.cs:71-74` already tallies `changedCount` but `:84-88` `needsRebuild` ignores it → H1 root cause. +- `VirtualTagHostActor.OnApply` (`:94-99`) spawns-new / stops-removed only; never respawns a changed child. Children are **auto-named** (`Context.ActorOf` with no name, `:103`) so respawn cannot hit the `#398` actor-name collision. +- `VirtualTag.Historize` column exists (`Configuration/Entities/VirtualTag.cs:48`); `ComputeGenerationDiff` already includes it in the VirtualTag CHECKSUM (so a Historize-only toggle is already a changed generation); `ConfigComposer.SnapshotAndFlattenAsync:45` serializes the whole `VirtualTag` entity with `DefaultIgnoreCondition.Never` → **the artifact JSON already contains `Historize`**. `DeploymentArtifact.BuildEquipmentVirtualTagPlans` simply doesn't read it. +- The equipment-namespace `VirtualTagActor` does **not** use `IHistoryWriter` (only the separate Core `VirtualTagEngine` does). `VirtualTagHostActor.OnResult` is the single clean seam — it already has the plan + NodeId. +- **There is no live-data historian WRITE RPC** (Wonderware client is HistoryRead + alarm-events only). So the *durable* AVEVA vtag-history sink is infra-gated; Phase 1 wires the seam + invokes it with a `NullHistoryWriter` default and records the durable sink as a flagged follow-up (do NOT build speculative `AddVirtualTagHistorian` config plumbing). + +**Hard constraints (every task):** NO EF migration / Configuration entity change. Stage by path — never `git add .`; never stage `sql_login.txt`, `src/Server/.../Host/pki/`, `pending.md`, `current.md`, `docker-dev/docker-compose.yml`. Never echo/commit secrets. No force-push, no `--no-verify`. TDD fail-then-pass. **No bUnit.** Production projects are `TreatWarningsAsErrors` — XML-doc any new public members. + +--- + +## Task 0: Feature branch + +**Classification:** trivial +**Estimated implement time:** ~1 min +**Parallelizable with:** none + +**Files:** none (git only) + +**Step 1:** From master at `f64be527`, create and switch to the branch: +```bash +git checkout master && git rev-parse --short HEAD # expect f64be527 (or later if master advanced) +git checkout -b feat/stillpending-phase-0-1 +``` +**Step 2:** Confirm `git status` shows only the pre-existing never-stage dirt (`docker-dev/docker-compose.yml`, `pending.md`, untracked `stillpending.md`). Do not stage them. + +--- + +## Task 1: Phase 0 — correct the 7 stale code comments + +**Classification:** trivial +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 3 + +**Files (comment text only — NO logic change):** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:45` (class-doc "For now the dispatch handler treats the apply as a no-op" — false; `ForwardToMux` + spawn-plan + live-value routing are fully wired) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:246-252` ("Live values arrive in a later milestone / BadWaitingForInitialData" — stale for tags & vtags; values land via `DriverHostActor.ForwardToMux` / `VirtualTagHostActor.OnResult`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ServiceCollectionExtensions.cs:66-67` ("login flow rewired to consume `IGroupRoleMapper` in a later task" — stale; already consumed in `AuthEndpoints.cs` / `LdapOpcUaUserAuthenticator.cs`) +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs:8` and `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs:10` ("concrete publisher supplied by a later task" — stale; `Runtime/Scripting/DpsScriptLogPublisher.cs` exists + wired) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs:21-23` (class-doc "placeholders returning empty results" — stale; Diagnose/Complete/Hover/SignatureHelp/Format all shipped; only InlayHints is intentionally empty — say exactly that) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs` (guard messages near `:866-871, :888-890, :1104-1107` referencing shipped "PR 4.4 / PR B.2 / legacy-host" and a never-added `Galaxy:Backend` switch) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs:21` ("PR 4.W revisits…") + +**Step 1:** Read each region, then replace the stale sentence with one accurate sentence describing current reality (keep it terse; do not delete surrounding valid doc). For the Galaxy guard messages, keep the guard logic identical — only correct the human-readable message/`PR x.y` references so a hit isn't misleading. + +**Step 2: Verify build (comments can break XML-doc references).** +Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` +Expected: build succeeds, 0 warnings (production projects are warnings-as-errors). + +**Step 3: Commit (stage exactly the touched files by path).** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Host/ServiceCollectionExtensions.cs \ + src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs \ + src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \ + src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \ + src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs +git commit -m "docs(comments): correct 7 stale 'later task/milestone' comments (stillpending §9)" +``` + +--- + +## Task 2: Phase 0 — fix docs/security.md + add benign-residue clarifying comments + +**Classification:** trivial +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 3 + +**Files:** +- Modify: `docs/security.md` (write-outcome section: "Bad-quality blip / AuditWriteUpdateEvent not surfaced" — stale; B1 implemented both. Update to describe the shipped behavior.) +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs:34,45` (add a one-line note that `GenerationId=0` / null `Enterprise`/`Site` / empty `PriorEquipment` are an intentional conservative fallback at the global factory level, not a bug — the path-length validator upper-bounds and cross-gen EquipmentUuid checks run elsewhere) +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Cluster/ClusterRoleInfo.cs:194` (note that `LeaderChanged` is intentionally a no-op here — ServiceLevel lives in `RedundancyStateActor`; this hook has no consumer by design) + +**Step 1:** Read `docs/security.md`'s write-outcome section; rewrite it to state that a failed inbound device write reverts the node to its prior value, surfaces the Bad-quality outcome, and emits an `AuditWriteUpdateEvent` (per B1, master `1d797c1c`). + +**Step 2:** Add the two clarifying code comments (no logic change). + +**Step 3: Verify** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds. + +**Step 4: Commit by path.** +```bash +git add docs/security.md \ + src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs \ + src/Core/ZB.MOM.WW.OtOpcUa.Core.Cluster/ClusterRoleInfo.cs +git commit -m "docs(security,core): correct stale write-outcome doc + note benign DraftSnapshot/LeaderChanged residue (stillpending §9/§3)" +``` + +--- + +## Task 3: Phase 0 — mark confirmed-shipped plan `.tasks.json` completed + +**Classification:** trivial +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 2 + +**Files (edit only the `.tasks.json`; do NOT touch the genuinely-open ones):** +- Modify the `status` fields to `"completed"` in the `.tasks.json` for the plans `stillpending.md §7` confirmed shipped: `global-uns-management`, `driver-browsers`, `documentation-audit`, `alarm-historian-followups`, `alarm-followups` (+round2), `residual-followups-cleanup`, `galaxy-phase-b`, `galaxy-phase-c` (code), `write-outcome`, `driver-typed-tag-editors`, `auth-alignment`, `equipment-relative-tag-paths`, `galaxy-standard-driver-phase-a`, `equipment-tag-live-values`, `alias-tag`, `akka-hosting-alignment`. +- **Do NOT modify** (genuinely open): `2026-05-28-adminui-driver-pages-plan`, `2026-06-07-per-cluster-scoping`, `2026-06-12-historian-tcp-transport`, `2026-05-29-adminui-followups`. + +**Step 1:** `ls docs/plans/*.tasks.json` to get exact filenames. For each shipped plan above, open its `.tasks.json` and set every `"status": "pending"` to `"completed"` and bump `lastUpdated` to `2026-06-15`. (These are bookkeeping files; no build impact.) + +**Step 2: Commit by path** (list the exact files you changed): +```bash +git add docs/plans/.tasks.json +git commit -m "chore(plans): mark confirmed-shipped .tasks.json completed so audits don't re-flag (stillpending §7)" +``` + +--- + +## Task 4: Phase 1 (H1a) — `Phase7Applier.needsRebuild` includes the `Changed*` counts + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 6 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:84-88` +- Test: the existing Phase7Applier test file under `tests/Server/…OpcUaServer.Tests/` (find it with `ls`/grep `Phase7Applier`); add the new tests there. + +**Step 1: Write the failing tests.** Add to the Phase7Applier test class, using the existing capturing/fake `IOpcUaAddressSpaceSink` already used by those tests (mirror an existing test's arrange): +```csharp +[Fact] +public void Apply_calls_RebuildAddressSpace_when_only_equipment_tags_changed() +{ + // plan with exactly one ChangedEquipmentTags delta, all Added/Removed empty + var plan = PlanWithOnlyChangedEquipmentTag(); // helper: build via Phase7Planner.Compute or direct ctor + var outcome = NewApplier(out var sink).Apply(plan); + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCallCount.ShouldBe(1); +} + +[Fact] +public void Apply_calls_RebuildAddressSpace_when_only_a_virtualtag_expression_changed() +{ + var plan = PlanWithOnlyChangedEquipmentVirtualTag(); + NewApplier(out var sink).Apply(plan); + sink.RebuildCallCount.ShouldBe(1); +} + +[Fact] +public void Apply_calls_RebuildAddressSpace_when_only_an_alarm_or_equipment_changed() +{ + // one ChangedAlarms (or ChangedEquipment) delta, everything else empty + var plan = PlanWithOnlyChangedAlarm(); + NewApplier(out var sink).Apply(plan); + sink.RebuildCallCount.ShouldBe(1); +} +``` +Use `Phase7Planner.Compute(prev, next)` with two compositions differing only in the relevant field to build these plans realistically (preferred over hand-constructing deltas, and it doubles as a Planner check). + +**Step 2: Run — expect FAIL** (today `needsRebuild` is false for changed-only plans). +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Phase7Applier"` +Expected: the 3 new tests FAIL (`RebuildCalled`/`RebuildCallCount` is false/0). + +**Step 3: Implement** — extend `needsRebuild` (`:84-88`) to OR-in the changed counts and remove the now-obsolete `TODO(equipment-virtualtags)` at `:83`: +```csharp +var needsRebuild = + plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || + plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || + plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || + plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || + plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || + plan.ChangedEquipmentTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0; +``` +(Driver-instance changes still do NOT force an address-space rebuild — they route through `DriverHostActor`'s spawn-plan, not the address space. Leave `ChangedDrivers` out and keep/refresh the comment saying so.) + +**Step 4: Run — expect PASS** (all Phase7Applier tests, incl. the existing ones). + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ +git commit -m "fix(deploy): rebuild address space on changed-only deploys (H1a, stillpending §1)" +``` + +--- + +## Task 5: Phase 1 (H1b) — `VirtualTagHostActor` stop+respawns children whose plan changed + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs:69-118` (`OnApply`) +- Test: the existing `VirtualTagHostActor` test file under `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` (find via grep). Uses Akka.TestKit. + +**Step 1: Write the failing test.** Mirror the existing host tests' harness (TestProbe for publish actor + mux). Apply a vtag, then re-apply the SAME `VirtualTagId` with a changed `Expression`/`DependencyRefs`, and assert the old child is stopped and a new child registers the NEW dependency refs with the mux: +```csharp +[Fact] +public void Re_applying_a_changed_vtag_respawns_the_child_with_new_dependency_refs() +{ + var mux = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(CreateTestProbe(), mux, NullVirtualTagEvaluator.Instance)); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { PlanFor("vt1", expr: "ctx.GetTag(\"A\")", deps: new[]{"A"}) })); + mux.ExpectMsg(m => m.Refs.SequenceEqual(new[]{"A"})); + + // same id, changed expression+deps → must stop old child + register the NEW refs + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { PlanFor("vt1", expr: "ctx.GetTag(\"B\")", deps: new[]{"B"}) })); + mux.ExpectMsg(); // old child PostStop + mux.ExpectMsg(m => m.Refs.SequenceEqual(new[]{"B"})); // new child PreStart +} + +[Fact] +public void Re_applying_an_unchanged_vtag_keeps_the_same_child() +{ + // applying twice with identical plans must NOT stop/respawn (no extra Register/Unregister) +} +``` +(Adjust message member names to the real `RegisterInterest`/`UnregisterInterest` shape — confirm via the actor source.) + +**Step 2: Run — expect FAIL** (today the unchanged-id branch `continue`s; no respawn). + +**Step 3: Implement.** Add a `private readonly Dictionary _planByVtag = new(StringComparer.Ordinal);` and in `OnApply`, BEFORE the rebuild of `_nodeIdByVtag`, stop+forget any existing child whose plan changed so the existing spawn loop recreates it: +```csharp +// Stop+forget children whose plan changed in place (Expression/DependencyRefs/Historize/etc.), +// so the spawn loop below recreates them with the new plan. Children are auto-named, so the +// respawn cannot collide on actor name (#398). PostStop unregisters the old mux interest. +foreach (var p in msg.Plans) +{ + if (_children.ContainsKey(p.VirtualTagId) + && _planByVtag.TryGetValue(p.VirtualTagId, out var prev) + && !prev.Equals(p)) + { + Context.Stop(_children[p.VirtualTagId]); + _children.Remove(p.VirtualTagId); + } +} +// … existing stop-removed + _nodeIdByVtag rebuild + spawn-new loop … +// At the end, refresh the plan map to exactly the desired set: +_planByVtag.Clear(); +foreach (var p in msg.Plans) _planByVtag[p.VirtualTagId] = p; +``` +Remove the obsolete `TODO(equipment-virtualtags)` at `:96-98`. Update the `:90-93` comment to say changed children are now respawned. + +**Step 4: Run — expect PASS** (new + existing host tests). +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHost"` + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ +git commit -m "fix(vtags): respawn equipment virtualtag child on in-place plan change (H1b, stillpending §1)" +``` + +--- + +## Task 6: Phase 1 (H5a) — `EquipmentVirtualTagPlan.Historize` + `Phase7Composer` reads it + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 4 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs:116-147` (add `Historize` to the record + `Equals`/`GetHashCode`) and `:381-388` (composer build site reads `v.Historize`) +- Test: the existing Phase7Composer test file under `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/`. + +**Step 1: Write the failing test.** Compose a generation containing a VirtualTag with `Historize = true` and assert the resulting `EquipmentVirtualTagPlan.Historize` is true (and false when the column is false). Mirror the existing composer-test arrange (in-memory `OtOpcUaConfigDbContext` or the existing fixture). +```csharp +[Fact] +public async Task Compose_carries_VirtualTag_Historize_onto_the_plan() +{ + // seed a VirtualTag row with Historize = true joined to a Script + var result = await Compose(...); + result.EquipmentVirtualTags.Single().Historize.ShouldBeTrue(); +} +``` + +**Step 2: Run — expect FAIL** (record has no `Historize` member → compile error first; that IS the red state). + +**Step 3: Implement.** +- Add `bool Historize` as the last positional parameter of `EquipmentVirtualTagPlan` (XML-doc it). Extend `Equals` to compare `Historize == other.Historize` and `GetHashCode` to `hash.Add(Historize)`. +- At `:381-388`, pass `Historize: v.Historize` (the entity column). Confirm `v` is the `VirtualTag` entity exposing `Historize` (it is — `Configuration/Entities/VirtualTag.cs:48`). + +**Step 4: Run — expect PASS.** + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ +git commit -m "feat(vtags): carry VirtualTag.Historize onto EquipmentVirtualTagPlan (H5a, stillpending §1)" +``` + +--- + +## Task 7: Phase 1 (H5b) — `DeploymentArtifact` reads `Historize` (byte-parity) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (depends on Task 6's record change) +**Blocked by:** Task 6 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:510-540` (`BuildEquipmentVirtualTagPlans` — read `Historize` from `el`, pass to the new ctor arg) +- Test: the existing DeploymentArtifact/parity test file under `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/`. + +**Step 1: Write the failing parity test.** The artifact JSON already contains `Historize` (ConfigComposer serializes the whole entity). Assert the decoded plan carries it, and assert composer-vs-artifact parity for a `Historize=true` vtag: +```csharp +[Fact] +public void Artifact_decode_carries_vtag_Historize_true() +{ + var blob = ArtifactWithVirtualTag(historize: true); // include "Historize": true on the vtag object + var comp = DeploymentArtifact.ParseComposition(blob); + comp.EquipmentVirtualTags.Single().Historize.ShouldBeTrue(); +} +``` +If a composer-vs-artifact byte-parity helper already exists for vtags (it does for the equipment-relative work), extend it to cover `Historize`. + +**Step 2: Run — expect FAIL** (decode ignores `Historize` → defaults false). + +**Step 3: Implement.** In `BuildEquipmentVirtualTagPlans`, read the bool (default false, matching the entity default and the absent/malformed convention used elsewhere in this file): +```csharp +var historize = el.TryGetProperty("Historize", out var hEl) + && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) + && hEl.GetBoolean(); +``` +Pass `Historize: historize` into the `new EquipmentVirtualTagPlan(...)` at `:532`. **Must stay byte-parity with `Phase7Composer`** — same default, same field. + +**Step 4: Run — expect PASS** (incl. existing parity tests). + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ +git commit -m "feat(vtags): decode VirtualTag Historize from artifact, byte-parity with composer (H5b, stillpending §1)" +``` + +--- + +## Task 8: Phase 1 (H5c) — `VirtualTagHostActor` invokes `IHistoryWriter` on historized results + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** Task 5 (same file), Task 7 (plan now carries `Historize`) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs` (Props + ctor add `IHistoryWriter`; `OnApply` track `Historize`; `OnResult` records) +- Test: the same `VirtualTagHostActor` test file as Task 5. + +**Step 1: Write the failing test.** Inject a fake `IHistoryWriter` that captures `Record` calls. Apply a `Historize=true` vtag, deliver a result, assert `Record` was called once with the published path + a snapshot carrying the value; apply a `Historize=false` vtag and assert `Record` is NOT called. +```csharp +sealed class CapturingHistoryWriter : IHistoryWriter { + public readonly List<(string Path, DataValueSnapshot Value)> Calls = new(); + public void Record(string path, DataValueSnapshot value) => Calls.Add((path, value)); +} +[Fact] +public void Historized_vtag_result_is_recorded_to_the_history_writer() { /* Historize:true → 1 call */ } +[Fact] +public void Non_historized_vtag_result_is_not_recorded() { /* Historize:false → 0 calls */ } +``` + +**Step 2: Run — expect FAIL** (Props has no `IHistoryWriter` param → compile error red state). + +**Step 3: Implement.** +- Add `IHistoryWriter` to `Props` and the ctor (default `NullHistoryWriter.Instance` so existing call sites/tests compile): `public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator, IHistoryWriter? historyWriter = null)`. Store `_history = historyWriter ?? NullHistoryWriter.Instance;`. +- Track historize per vtag: in `OnApply` build `_historizeByVtag[p.VirtualTagId] = p.Historize` alongside `_nodeIdByVtag` (clear+rebuild together). +- In `OnResult`, after the existing publish, if `_historizeByVtag.TryGetValue(result.VirtualTagId, out var hist) && hist`, call `_history.Record(nodeId, new DataValueSnapshot(result.Value, /*Good*/ 0u, result.TimestampUtc, result.TimestampUtc));` (use the same `nodeId` already resolved for the publish; reuse the project's Good status constant if one exists rather than the literal `0u`). +- XML-doc the new param. + +**Step 4: Run — expect PASS.** +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHost"` + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ +git commit -m "feat(vtags): forward historized vtag results to IHistoryWriter (H5c, stillpending §1)" +``` + +--- + +## Task 9: Phase 1 (H5d) — thread `IHistoryWriter` through `DriverHostActor` + DI (Null default) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** Task 8 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:211-228` (Props adds `IHistoryWriter? historyWriter = null`), `:330` (pass it into `VirtualTagHostActor.Props`), and the field/ctor that stores it. +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:211-221` (resolve `IHistoryWriter` from the provider — default `NullHistoryWriter.Instance` — and pass to `DriverHostActor.Props`). Register `services.AddSingleton(NullHistoryWriter.Instance)` in `AddOtOpcUaRuntime` if not already present. +- Test: a `DriverHostActor` test under `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` asserting the host passes the injected writer to the real `VirtualTagHostActor` — OR, if that's awkward with the `virtualTagHostOverride` seam, assert the Props default is `NullHistoryWriter` and the param is threaded (a small construction test). Keep it lightweight. + +**Step 1: Write the failing test** (Props signature gains a param; a construction/threading assertion). + +**Step 2: Run — expect FAIL** (param doesn't exist yet). + +**Step 3: Implement** the threading: `DriverHostActor.Props(... , IHistoryWriter? historyWriter = null, ...)` → store `_historyWriter = historyWriter ?? NullHistoryWriter.Instance` → at `:330` `VirtualTagHostActor.Props(_opcUaPublishActor, _dependencyMux, _virtualTagEvaluator, _historyWriter)`. In `ServiceCollectionExtensions`, `var historyWriter = sp.GetService() ?? NullHistoryWriter.Instance;` and pass it. **Do not** add an `AddVirtualTagHistorian` config-gated extension — the durable sink is infra-gated (no backend write RPC); the Null default is the correct production wiring until a sink exists. + +**Step 4: Run — expect PASS;** then full project test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests`. + +**Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ +git commit -m "feat(vtags): wire IHistoryWriter through DriverHostActor (Null default; durable sink infra-gated) (H5d, stillpending §1)" +``` + +--- + +## Task 10: Phase 1 — docs + follow-up bookkeeping + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none +**Blocked by:** Task 9 + +**Files:** +- Modify: `docs/VirtualTags.md` (note: equipment virtual-tag `Historize` is now honored at runtime — results forward to the configured `IHistoryWriter`; the durable AVEVA data-value sink is infra-gated, pending a sidecar write RPC). +- Modify: `docs/plans/2026-06-15-stillpending-backlog-design.md` (append to the defer list: "durable AVEVA vtag-history sink — infra-gated, same constraint as the HistoryUpdate service"). +- Update `pending.md` (working-tree only — **do NOT git add it**) with the Phase 0 + Phase 1 entries. + +**Step 1:** Make the doc edits. Add a one-line follow-up that the durable vtag-history sink needs a sidecar `WriteDataValues` RPC. + +**Step 2: Commit (docs only — never `pending.md`).** +```bash +git add docs/VirtualTags.md docs/plans/2026-06-15-stillpending-backlog-design.md +git commit -m "docs(vtags): document runtime Historize honoring + infra-gated durable sink (Phase 1)" +``` + +--- + +## Task 11: Phase 0+1 — full build + test + final integration review + +**Classification:** high-risk +**Estimated implement time:** ~5 min (mostly CI wall-time) +**Parallelizable with:** none +**Blocked by:** Tasks 1–10 + +**Files:** none (verification only) + +**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — 0 warnings/errors. +**Step 2:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — all green (unit suites; integration suites needing Docker/SQL may be skip-gated — note any skips, don't treat as failures). +**Step 3:** Dispatch the final integration reviewer over `git diff master..HEAD` for the whole branch (H1 deploy-path correctness + H5 byte-parity + actor-lifecycle safety of the respawn). +**Step 4:** Hand off to `superpowers-extended-cc:finishing-a-development-branch` (verify tests → present merge options). H1/H5 carry a **user-driven live `/run`** gate on docker-dev (edit equipment/alarm/vtag → confirm it takes effect without restart; toggle a vtag `Historize` → confirm the host respawns and the writer is invoked) — surface this as the post-merge verification, do not self-run it. + +--- + +## Execution notes +- Tasks 1, 2, 3 (Phase 0) are mutually independent → dispatch concurrently. +- Tasks 4, 5, 6 are mutually independent (disjoint files) → dispatch concurrently; 7→8→9→10 are a serial chain. +- Phase 0 must land before Task 9 (both touch `DriverHostActor.cs` / `ServiceCollectionExtensions.cs`); since Phase 0 runs first this is satisfied. diff --git a/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json new file mode 100644 index 00000000..229b845a --- /dev/null +++ b/docs/plans/2026-06-15-stillpending-phase-0-1.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md", + "tasks": [ + {"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"}, + {"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]}, + {"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]}, + {"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]}, + {"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]}, + {"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]}, + {"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]}, + {"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]}, + {"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]}, + {"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]}, + {"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]}, + {"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]} + ], + "lastUpdated": "2026-06-15" +}