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

28 KiB
Raw Blame History

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 migrationHistorize 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.Neverthe 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:

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

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.

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):

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):

[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:

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.

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:

[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 continues; 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:

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

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

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

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:

[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):

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.

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.

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.

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.

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

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.