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.
This commit is contained in:
Joseph Doherty
2026-06-15 09:40:03 -04:00
parent f64be52796
commit 68a0f759f0
2 changed files with 469 additions and 0 deletions
@@ -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/<each-shipped>.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/<file>
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<DependencyMuxActor.RegisterInterest>(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<DependencyMuxActor.UnregisterInterest>(); // old child PostStop
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(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<string, EquipmentVirtualTagPlan> _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/<file>
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/<file>
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/<file>
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/<file>
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<IHistoryWriter>(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<IHistoryWriter>() ?? 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/<file>
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 110
**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.
@@ -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"
}