Files
lmxopcua/docs/plans/2026-06-15-stillpending-phase-0-1.md
T
Joseph Doherty 68a0f759f0 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 09:40:03 -04:00

452 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.