Re-ran all 8 domain reviews at HEAD8c888f13against theb910f5ebbaseline: every round-1 finding source-verified (168 fixed, 0 regressions, 0 false claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low), concentrated in post-baseline code (anti-entropy resync, KPI rollup backfill, live alarm stream) and seams the fixes exposed. Headliners: S&F resync predicate inversion can wipe the delivering node's buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame size (02-N2); failover drill kills the one node keep-oldest can't survive (01-N1); unbounded rollup backfill per failover (04-R1); live production API key in untracked test.txt (08-NF1). Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board, P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
56 KiB
PLAN-R2-01 — Cluster, Host & Failover Round-2 Fixes Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Make the failover story honest — rewrite and actually RUN the failover drill so it proves what two-node keep-oldest can deliver (and measures what it cannot), apply the three wonder-app-vd03 overlay edits that were deferred on a factually wrong rationale, and close the round-2 residuals (never-reported metrics staleness, stale leader comments, undrained site singletons).
Architecture: The drill (docker/failover-drill.sh) gains a DRILL_MODE — standby (default, the survivable younger-node crash that PASSes within the ~25s SBR budget) and active (the oldest-node crash that keep-oldest cannot survive: the drill expects the outage and measures it, documenting the registered deferred user decision instead of codifying a recovery the topology cannot deliver). The in-process envelope measurement reuses TwoNodeClusterFixture (production BuildHocon, now with production-timing knobs) from the previously-skipped FailoverTimingTests. Site-role singleton drains come from generalizing CentralSingletonRegistrar into a role-aware SingletonRegistrar so deployment-manager and event-log-handler get the same PhaseClusterLeave GracefulStop the seven central singletons got in round 1.
Tech Stack: C#/.NET, Akka.NET 1.5.62 (Akka.Cluster, Akka.Cluster.Tools), xUnit, bash + Docker Compose + Traefik, JSON deploy overlays (PowerShell install.ps1).
Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx. Test per-project: dotnet test tests/<project>. Docker rig: bash docker/deploy.sh.
Binding scope rulings (do not deviate):
- The two-node keep-oldest active/oldest-crash availability gap is a registered deferred USER decision (master tracker 2026-07-08 "Follow-up discovered during P0 execution";
SbrFailoverTests.cs:15-23). No task in this plan changes the SBR strategy or topology — Tasks 1–3 document and measure the gap. - The oldest-vs-leader predicate unification inside
SiteCommunicationActor/SiteReplicationActorand everything resync-related is owned by PLAN-R2-02 — do not touch those files here. - N6 (deferred-work-register consolidation) is owned by PLAN-R2-08 — Task 7 here corrects only the factually wrong N2 prose in the master tracker.
/deploy/is gitignored (.gitignore:48: /deploy/) — Tasks 5 and 6 edit the on-disk artifact and deliberately do NOTgit addit (never usegit add -f); Task 7's tracker correction is the version-controlled record.
Task 1: Rewrite failover-drill.sh — standby-victim default + explicit active-victim gap mode
Classification: small Estimated implement time: ~5 min Parallelizable with: Task 4, Task 5, Task 6, Task 8, Task 9, Task 10 Files:
- Modify:
docker/failover-drill.sh(full rewrite — current script is 45 lines; lines 12-19 pick the ACTIVE node as victim, which is exactly the crash keep-oldest cannot survive)
Context (round-2 N1): under the round-1 oldest-member unification, the active node IS the oldest. docker/failover-drill.sh:17-19 kills it, so the shipped drill exercises the one crash two-node keep-oldest is known not to recover from (the survivor self-downs; the restarted central-b cannot re-bootstrap because both nodes list scadabridge-central-a as FIRST seed — docker/central-node-a/appsettings.Central.json:10-13, docker/central-node-b/appsettings.Central.json:10-13 — and only the first seed may self-join to form a new cluster). The drill has also never been run (docker/README.md:288). Fix the drill so its PASS criteria match reality per direction.
- No unit test (bash script). The verification command is the "test":
bash -n docker/failover-drill.sh(must exit 0), plusDRILL_MODE=bogus bash docker/failover-drill.sh(must exit 2 with the usage error — this exercises the argument guard without needing a cluster). - Replace the script's entire contents with:
#!/usr/bin/env bash
# Failover drill against the running docker cluster (bash docker/deploy.sh first).
#
# ROUND-2 REWRITE (arch-review 01 round 2, N1). The original drill killed the
# ACTIVE central node — but under the unified oldest-member semantics the
# active node IS the oldest, i.e. the one crash two-node keep-oldest CANNOT
# survive (registered deferred user decision, master tracker 2026-07-08;
# SbrFailoverTests.cs XML doc). Two modes:
#
# DRILL_MODE=standby (default) — kills the STANDBY (younger) central node.
# The survivable direction: SBR downs the crashed member, the active node
# keeps its singletons, and Traefik routing never goes dark. PASS = the
# survivor logs the member removal within TIMEOUT_S (budget ~25s+: 10s
# failure detection + 15s stable-after) while /health/active stays up.
#
# DRILL_MODE=active — kills the ACTIVE (oldest) central node. THE EXPECTED
# OUTCOME IS A TOTAL CENTRAL OUTAGE: keep-oldest downs the partition
# without the oldest, so the younger survivor downs ITSELF (down-if-alone
# cannot help — the alone-oldest is dead and cannot down itself), and the
# self-downed survivor cannot re-form a cluster alone unless it is the
# FIRST seed (both nodes list central-a first; only the first seed may
# self-join). This mode measures the dark window and PASSes only when
# central recovers AFTER the victim container is restarted. It exists to
# make the registered gap observable — not to pretend it is covered.
set -euo pipefail
TRAEFIK_URL="${TRAEFIK_URL:-http://localhost:9000}"
TIMEOUT_S="${TIMEOUT_S:-90}"
DRILL_MODE="${DRILL_MODE:-standby}"
OUTAGE_CONFIRM_S="${OUTAGE_CONFIRM_S:-60}"
active_container() {
if curl -sf -o /dev/null "http://localhost:9001/health/active"; then echo scadabridge-central-a
elif curl -sf -o /dev/null "http://localhost:9002/health/active"; then echo scadabridge-central-b
else echo "ERROR: no active central node found" >&2; exit 1; fi
}
peer_of() { [ "$1" = scadabridge-central-a ] && echo scadabridge-central-b || echo scadabridge-central-a; }
case "$DRILL_MODE" in
standby|active) ;;
*) echo "ERROR: DRILL_MODE must be 'standby' or 'active' (was '$DRILL_MODE')" >&2; exit 2 ;;
esac
ACTIVE=$(active_container)
if [ "$DRILL_MODE" = standby ]; then
VICTIM=$(peer_of "$ACTIVE"); SURVIVOR="$ACTIVE"
else
VICTIM="$ACTIVE"; SURVIVOR=$(peer_of "$ACTIVE")
fi
echo "mode=${DRILL_MODE} active=${ACTIVE} victim=${VICTIM} survivor=${SURVIVOR}"
KILL_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker kill "${VICTIM}" > /dev/null
START=$(date +%s)
if [ "$DRILL_MODE" = standby ]; then
echo "Standby crash: waiting for ${SURVIVOR} to DOWN+REMOVE the dead member (SBR budget ~25s)..."
BLIPS=0
while true; do
ELAPSED=$(( $(date +%s) - START ))
curl -sf -o /dev/null "${TRAEFIK_URL}/health/active" || BLIPS=$((BLIPS + 1))
if docker logs --since "${KILL_AT}" "${SURVIVOR}" 2>&1 | grep -Eiq "marking.*node.*down|member removed|is removed"; then
echo "PASS: survivor removed the crashed member in ${ELAPSED}s (budget ~25s: 10s detection + 15s stable-after)."
echo "Active-node routing blips during the drill: ${BLIPS} (expected 0 — the active node was never touched)."
break
fi
if (( ELAPSED > TIMEOUT_S )); then
echo "FAIL: no downing/removal evidence on ${SURVIVOR} after ${ELAPSED}s — SBR did not act" >&2
docker start "${VICTIM}" > /dev/null
exit 1
fi
sleep 1
done
else
echo "Active crash: EXPECTING a central outage (registered keep-oldest gap). Watching /health/active..."
DARK_STREAK=0
while true; do
ELAPSED=$(( $(date +%s) - START ))
if curl -sf -o /dev/null "${TRAEFIK_URL}/health/active"; then DARK_STREAK=0; else DARK_STREAK=$((DARK_STREAK + 1)); fi
if (( DARK_STREAK >= 10 )); then
echo "Outage confirmed at ${ELAPSED}s: no active central node — the younger survivor self-downed"
echo "(keep-oldest downs the partition WITHOUT the oldest; this is the registered deferred gap)."
break
fi
if (( ELAPSED > OUTAGE_CONFIRM_S )); then
echo "NOTE: /health/active stayed reachable ${ELAPSED}s after killing the oldest — better than the"
echo "registered gap predicts. Do NOT celebrate: capture both nodes' logs and investigate before trusting it."
break
fi
sleep 1
done
fi
echo "Restarting ${VICTIM}..."
docker start "${VICTIM}" > /dev/null
RESTART_AT=$(date +%s)
echo "Waiting for central to be routable again through Traefik (${TRAEFIK_URL}/health/active)..."
while true; do
ELAPSED=$(( $(date +%s) - RESTART_AT ))
if curl -sf -o /dev/null "${TRAEFIK_URL}/health/active"; then
echo "Recovered: an active central node is routable ${ELAPSED}s after the victim restart."
break
fi
if (( ELAPSED > 120 )); then
echo "FAIL: central not routable 120s after restarting ${VICTIM}" >&2
exit 1
fi
sleep 1
done
echo "Survivor singleton/downing evidence (last 20 matching log lines from ${SURVIVOR}):"
docker logs "${SURVIVOR}" 2>&1 | grep -Ei "singleton|oldest|downing|removed" | tail -20 || true
echo "Drill complete (${DRILL_MODE}). Verify on the Health dashboard that both nodes show Up and exactly one is Primary."
chmod +x docker/failover-drill.sh. Verify:bash -n docker/failover-drill.sh(exit 0) andDRILL_MODE=bogus bash docker/failover-drill.sh; echo "exit=$?"(prints the usage error, exit=2).- Commit:
git add docker/failover-drill.sh
git commit -m "fix(docker): failover drill kills the STANDBY by default; active mode measures the registered keep-oldest outage instead of pretending recovery (plan R2-01 T1)"
Task 2: Correct the recovery narrative + document the first-seed bootstrap constraint
Classification: trivial (doc-only) Estimated implement time: ~5 min Parallelizable with: Task 4, Task 5, Task 6, Task 8, Task 9, Task 10 Files:
- Modify:
docker/README.md(Failover Testing → "Automated failover drill" section, lines 273-288) - Modify:
docs/requirements/Component-ClusterInfrastructure.md("Down-if-alone recovery" subsection, lines ~104-116)
docker/README.md(lines 273-288): rewrite the drill section to match Task 1's two modes. Specifically:- Replace the four numbered bullets with per-mode descriptions:
DRILL_MODE=standby(default) = the survivable younger-crash direction — expected result: no routing outage at all (the active node is never touched) and member removal on the survivor within ~25s (10s failure-detection threshold + 15s stable-after; the 2s heartbeat interval is not additive);DRILL_MODE=active= the oldest-crash direction — expected result: total central outage until the victim container is restarted (the registered deferred keep-oldest decision, master tracker 2026-07-08), then recovery within ~2 min of the restart. - Delete the line-286 promise "Expected failover: ~25s … plus the Traefik ~5s health-check interval" — it described a singleton migration on active-crash that two-node keep-oldest cannot deliver. State per-direction expectations instead.
- Replace the line-288 "Observed failover time: run after next deploy and record here" placeholder with a two-row results table Task 3 will fill:
- Replace the four numbered bullets with per-mode descriptions:
> **Observed results** (recorded by plan R2-01 T3, cluster @ commit <sha>):
>
> | Direction (`DRILL_MODE`) | Outcome | Measured |
> |--------------------------|---------|----------|
> | `standby` (younger-node crash) | _pending T3_ | _pending T3_ |
> | `active` (oldest-node crash) | _pending T3_ | _pending T3_ |
- Add a short "Seed-node bootstrap constraint" note to the same section: only the FIRST seed in
Cluster:SeedNodesmay self-join to form a new cluster; both central nodes listscadabridge-central-afirst, so a lone restartedcentral-b(withcentral-astill down) loops onInitJoinforever — neverUp, never/health/active200. Operator recovery actions: (1) restart the dead first-seed node (preferred), or (2) restart the survivor with a self-first seed override (env:ScadaBridge__Cluster__SeedNodes__0=akka.tcp://scadabridge@<self-host>:8081,...__1=<peer>). Explain why the repo does NOT ship self-first ordering per node: with both nodes self-first, a simultaneous cold start can let each self-join independently → two one-node clusters that never merge (a cold-start split-brain the identical-seed-order convention exists to prevent). The real remedy is the pending keep-oldest topology/strategy decision (deferred, owner: user).
docs/requirements/Component-ClusterInfrastructure.md(Down-if-alone recovery, ~104-116):- Amend step 4 ("The restarted process rejoins as a fresh incarnation (the keep-oldest resolver handles the rejoin cleanly…)"): add the qualifier that this holds only while a peer holding cluster state is still reachable — a lone restarted non-first-seed node cannot re-form a cluster (first-seed self-join rule) and waits for its peer.
- Add a "Seed-node bootstrap constraint" paragraph after the numbered list (same content as the README note, spec-toned), cross-referencing the registered deferred keep-oldest decision and the operator actions.
- Fix the closing line "The docker failover drill exercises this loop end-to-end (kill the active node, assert singleton migration + clean rejoin)" → "The docker failover drill (
docker/failover-drill.sh) exercises this per direction:standbymode proves SBR downing + singleton continuity on the oldest;activemode measures the registered total-outage gap and the recovery-on-restart path."
- Verification (doc task — no test):
grep -n "DRILL_MODE" docker/README.md(≥2 hits),grep -cn "singleton migration" docs/requirements/Component-ClusterInfrastructure.md(0 hits in the recovery section),grep -n "first seed\|first-seed" docker/README.md docs/requirements/Component-ClusterInfrastructure.md(≥2 hits),grep -n "Traefik ~5s health-check interval" docker/README.md(0 hits). - Commit:
git add docker/README.md docs/requirements/Component-ClusterInfrastructure.md
git commit -m "docs(cluster): per-direction failover truth — first-seed bootstrap constraint + operator recovery actions; drop the undeliverable ~25s active-crash promise (plan R2-01 T2)"
Task 3: RUN the drill live (both directions) and record measured timings
Classification: standard (live-cluster verification; ~5 min of work, several minutes of wall-clock waiting)
Estimated implement time: ~5 min
Parallelizable with: none (exclusive use of the shared docker cluster; writes docker/README.md)
Files:
- Modify:
docker/README.md(fill the Task 2 results table)
The drill has NEVER been executed (round-2 N1 point 3, docker/README.md:288). This task produces the empirical record — the asymmetric result is the documentation the deferred keep-oldest decision needs.
- Ensure infra + cluster are up and freshly built (drill needs the Task 1 script inside the checkout only — no image change required, but the cluster must be healthy):
cd infra && docker compose up -d && cd ..
bash docker/deploy.sh
Wait until curl -sf http://localhost:9000/health/active returns 200 (poll; allow ~2 min for first-boot readiness).
2. Run direction 1 and capture:
DRILL_MODE=standby bash docker/failover-drill.sh 2>&1 | tee /private/tmp/claude-501/-Users-dohertj2-Desktop-ScadaBridge/d69633c2-b169-4fe1-8fb4-0d8007a16fa1/scratchpad/drill-standby.log
Expected: PASS with member-removal time ≈ 25-30s and routing blips: 0. Wait for the victim to fully rejoin (script does this) plus ~30s settle time.
3. Run direction 2 and capture:
DRILL_MODE=active bash docker/failover-drill.sh 2>&1 | tee /private/tmp/claude-501/-Users-dohertj2-Desktop-ScadaBridge/d69633c2-b169-4fe1-8fb4-0d8007a16fa1/scratchpad/drill-active.log
Expected: "Outage confirmed at ~Ns" (survivor self-downs; central dark), then "Recovered: … Ms after the victim restart". If instead the NOTE branch fires (/health/active stayed up), capture docker logs scadabridge-central-a + -b and investigate before recording — do not record a result that contradicts the registered gap without evidence.
4. Fill the docker/README.md results table from the two logs: outcome + measured seconds per direction, and the cluster commit SHA (git rev-parse --short HEAD). Keep the raw one-line summary quotes from the script output.
5. Sanity-restore: confirm both nodes healthy afterwards — curl -sf http://localhost:9001/health/ready && curl -sf http://localhost:9002/health/ready (both 200; the standby's /health/active correctly 503).
6. Commit:
git add docker/README.md
git commit -m "test(docker): record live failover-drill results for both crash directions — standby-crash PASS timing + active-crash measured outage (plan R2-01 T3)"
Task 4: Wire FailoverTimingTests to TwoNodeClusterFixture — measure the ~25s envelope in-process
Classification: high-risk (real two-node cluster timing test) Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 5, Task 6, Task 7, Task 8, Task 9, Task 10, Task 11 Files:
- Modify:
tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs(StartAsync lines 24-36, StartNode lines 39-65 — add production-timing knobs) - Modify:
tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/ZB.MOM.WW.ScadaBridge.PerformanceTests.csproj(add a ProjectReference) - Modify:
tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs(replace theSkipplaceholder — currently[Fact(Skip = "Requires the real two-node failover rig delivered by PLAN-01 …")]at lines 25-32)
This task also covers architecture-review report 08's NF2 by reference (the skipped perf placeholder): the rig it waits for exists (TwoNodeClusterFixture, PLAN-01 T3); wire it up. The fixture hard-codes scaled timings (HeartbeatInterval 500ms / FailureDetectionThreshold 2s at TwoNodeClusterFixture.cs:51-52); to measure the production ~25s envelope (2s heartbeat / 10s threshold / 15s stable-after), the knobs become optional parameters with the current values as defaults (additive — SbrFailoverTests/ActiveNodeSemanticsTests unaffected). Direction note: the measurable crash is the YOUNGER node (the survivable direction); the oldest-crash direction is the registered deferred gap and is not a recovery to time — say so in the test's XML doc.
- Rewrite
FailoverTimingTests.cs(this is the failing step — it will not compile until the fixture knobs + project reference exist):
using System.Diagnostics;
using Akka.Actor;
using Akka.Cluster.Tools.Singleton;
using Xunit.Abstractions;
using ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Failover;
/// <summary>
/// Failover-timing measurement on the real two-node in-process rig
/// (TwoNodeClusterFixture, production BuildHocon) at PRODUCTION timings:
/// 2s heartbeat / 10s failure-detection threshold / 15s SBR stable-after —
/// the CLAUDE.md "total failover ~25s" design envelope.
///
/// Measures the SURVIVABLE direction only: hard-crash of the YOUNGER node,
/// timed to the survivor's member REMOVAL (detection + stable-after + gossip)
/// with singleton continuity asserted on the oldest. The oldest/active-node
/// crash is NOT a recovery to time — two-node keep-oldest makes the younger
/// survivor down itself (total outage; registered deferred user decision,
/// see SbrFailoverTests XML doc + master tracker 2026-07-08). Covers overall
/// review P2-10 / report-08 NF2 and report-01 round-2 N1's measurement ask.
/// </summary>
public class FailoverTimingTests(ITestOutputHelper output)
{
private sealed class EchoActor : ReceiveActor
{
public EchoActor() => ReceiveAny(msg => Sender.Tell(msg));
}
[Trait("Category", "Performance")]
[Fact]
public async Task HardKillOfYoungerNode_SbrRemovalWithinDesignEnvelope()
{
await using var cluster = await TwoNodeClusterFixture.StartAsync(
stableAfter: TimeSpan.FromSeconds(15),
heartbeatInterval: TimeSpan.FromSeconds(2),
failureDetectionThreshold: TimeSpan.FromSeconds(10));
// Singleton on the oldest (NodeA) — the continuity probe.
var manager = cluster.NodeA.ActorOf(ClusterSingletonManager.Props(
Props.Create(() => new EchoActor()),
PoisonPill.Instance,
ClusterSingletonManagerSettings.Create(cluster.NodeA).WithSingletonName("timing-probe")),
"timing-probe-singleton");
var proxyA = cluster.NodeA.ActorOf(ClusterSingletonProxy.Props(
"/user/timing-probe-singleton",
ClusterSingletonProxySettings.Create(cluster.NodeA).WithSingletonName("timing-probe")),
"timing-probe-proxy");
Assert.Equal("ping", await proxyA.Ask<string>("ping", TimeSpan.FromSeconds(30)));
var victim = Akka.Cluster.Cluster.Get(cluster.NodeB).SelfAddress;
var sw = Stopwatch.StartNew();
await TwoNodeClusterFixture.CrashNode(cluster.NodeB);
await TwoNodeClusterFixture.WaitForMemberRemoved(cluster.NodeA, victim, TimeSpan.FromSeconds(60));
sw.Stop();
output.WriteLine(
$"MEASURED: crash -> member removed on survivor: {sw.Elapsed.TotalSeconds:F1}s " +
"(design ~25s = 10s detection + 15s stable-after; +gossip margin)");
// Design envelope: >= stable-after (SBR must not act early), <= 25s design + 15s margin.
Assert.InRange(sw.Elapsed, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(40));
// Singleton continuity on the surviving oldest node.
Assert.Equal("ping-after-crash",
await proxyA.Ask<string>("ping-after-crash", TimeSpan.FromSeconds(10)));
}
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~FailoverTimingTests"— expect FAIL (compile error:TwoNodeClusterFixtureunknown; noheartbeatIntervalparameter). - Implement:
TwoNodeClusterFixture.StartAsync(line 24): signature →StartAsync(string role = "Central", TimeSpan? stableAfter = null, TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null); pass both new args through the twof.StartNode(...)calls.TwoNodeClusterFixture.StartNode(line 39): signature →StartNode(int port, string role, TimeSpan? stableAfter = null, TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null); lines 51-52 becomeHeartbeatInterval = heartbeatInterval ?? TimeSpan.FromMilliseconds(500),/FailureDetectionThreshold = failureDetectionThreshold ?? TimeSpan.FromSeconds(2),. Extend the class XML doc: "Timing knobs default to scaled test values; pass production values (2s/10s/15s) to measure the real failover envelope (FailoverTimingTests)."ZB.MOM.WW.ScadaBridge.PerformanceTests.csproj: add to the existing<ItemGroup>of ProjectReferences:
<!-- Two-node failover rig (TwoNodeClusterFixture) — brings Host + Akka transitively. -->
<ProjectReference Include="../../tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj" />
- Run again:
dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~FailoverTimingTests"— expect PASS in ~45-90s; the test output MUST print theMEASURED:line — quote the number in the commit message. Regression:dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter "FullyQualifiedName~SbrFailoverTests|FullyQualifiedName~ActiveNodeSemanticsTests|FullyQualifiedName~TwoNodeClusterFixtureTests"— all green (fixture change is additive). - Commit:
git add tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/ZB.MOM.WW.ScadaBridge.PerformanceTests.csproj tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs
git commit -m "test(failover): unskip FailoverTimingTests on the two-node rig at production timings — measured Xs vs ~25s design envelope (plan R2-01 T4; covers report-08 NF2)"
Task 5: Apply the wonder-app-vd03 appsettings overlay edits (NodeName + AllowSingleNodeCluster, drop phantom seeds)
Classification: trivial (config-only; NO git commit — see note) Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 4, Task 6, Task 8, Task 9, Task 10, Task 11 Files:
- Modify:
deploy/wonder-app-vd03/appsettings.Central.json(Nodelines 3-7,Clusterlines 8-18,_comment_Clusterline 19) - Modify:
deploy/wonder-app-vd03/appsettings.Site.json(Nodelines 3-9,Clusterlines 10-20,_comment_Clusterline 21)
Round-2 N2: the round-1 deferral claimed the artifact "is not tracked in this repo … the artifact directory itself is not present" — the directory IS present (merely gitignored, .gitignore:48: /deploy/) and has been actively maintained in this working tree (its _comment_Security documents the PLAN-07 edit). Apply the two appsettings edits from PLAN-01 Tasks 16/23 directly. Without NodeName, every audit row from the only production install stamps a NULL SourceNode (defeats IX_AuditLog_Node_Occurred + the per-node stuck KPIs); with the phantom seeds, each node dials a nonexistent peer (localhost:8091 / localhost:8092) forever.
appsettings.Central.json:Node(after"Role": "Central",): add"NodeName": "central-a",(matches the docker convention,docker/central-node-a/appsettings.Central.json).Cluster: drop the phantom seed"akka.tcp://scadabridge@localhost:8091"(keep only the:8081self seed) and add"AllowSingleNodeCluster": true(flag shipped in PLAN-01 T16:ClusterOptions.cs:86, validatorClusterOptionsValidator.cs:29-35requires it for a 1-seed list)._comment_Cluster→"Single-node central install, explicitly acknowledged via AllowSingleNodeCluster (arch-review 01 S12/N2): the previous phantom second seed (localhost:8091) made the node dial a nonexistent peer forever. When a real central node B is added: list BOTH real seeds and remove AllowSingleNodeCluster."
appsettings.Site.json: same treatment —Nodegains"NodeName": "node-a",;Cluster.SeedNodeskeeps only"akka.tcp://scadabridge@localhost:8082"; add"AllowSingleNodeCluster": true;_comment_Clusterrewritten equivalently (PRESERVE the existing "Seed ports must never equal GrpcPort (8083)" sentence).- Verify (this is the "test" — config task):
python3 -m json.tool deploy/wonder-app-vd03/appsettings.Central.json > /dev/null && \
python3 -m json.tool deploy/wonder-app-vd03/appsettings.Site.json > /dev/null && \
grep -c "NodeName\|AllowSingleNodeCluster" deploy/wonder-app-vd03/appsettings.Central.json deploy/wonder-app-vd03/appsettings.Site.json && \
grep -c "8091\|8092" deploy/wonder-app-vd03/appsettings.Central.json deploy/wonder-app-vd03/appsettings.Site.json
Expect: both json.tool exits 0; each file counts 2 for the first grep; each file counts 0 for the phantom-port grep (grep -c exits 1 on zero matches — that is the pass signal here).
4. No git commit — /deploy/ is gitignored (.gitignore:48); do NOT use git add -f. The edit is intentionally working-tree/on-host only; Task 7 commits the version-controlled record, and the change takes effect on the host when the artifact is next synced + services restarted (RUNBOOK step added in Task 6).
Task 6: Complete the install.ps1 recovery actions (sc.exe failureflag) + RUNBOOK recovery step
Classification: trivial (script/doc-only; NO git commit — see Task 5 note) Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 8, Task 9, Task 10, Task 11 Files:
- Modify:
deploy/wonder-app-vd03/install.ps1(section# --- 7. Auto-restart on failure, lines ~171-174) - Modify:
deploy/wonder-app-vd03/RUNBOOK.md(troubleshooting table, ~line 218 area)
Finding-vs-disk note (record this honestly): the round-2 report says the sc.exe failure recovery actions are missing, but the on-disk install.ps1 already has them (lines 171-174, all three services) — what is missing is sc.exe failureflag <svc> 1. Without the failure-actions flag, the SCM runs recovery actions only after a crash; the down-if-alone watchdog's deliberate process exit (IHostApplicationLifetime.StopApplication() after an SBR self-down — AkkaHostedService.cs:203-217) is a non-crash stop and would never be restarted, breaking the recovery contract (Component-ClusterInfrastructure.md step 3: "The service supervisor restarts it"). This is the exact second line PLAN-01 Task 20 specified.
- In
install.ps1, extend the existing loop:
# --- 7. Auto-restart on failure ------------------------------------------
foreach ($svc in 'ScadaBridge-LDAP', 'ScadaBridge-Central', 'ScadaBridge-Site') {
sc.exe failure $svc reset= 86400 actions= restart/5000/restart/5000/restart/10000 | Out-Null
# Required for the SBR down-if-alone recovery contract
# (Component-ClusterInfrastructure.md "Down-if-alone recovery"): without
# failureflag=1 the SCM applies the recovery actions only after a CRASH;
# the Host watchdog's deliberate process exit after a self-down is a
# normal (non-crash) stop and would otherwise never be restarted.
sc.exe failureflag $svc 1 | Out-Null
}
RUNBOOK.md: add a troubleshooting-table row:| Service stopped on its own with log line "ActorSystem terminated outside host shutdown" | SBR self-down (down-if-alone). The service recovery actions restart it automatically (requires install.ps1 with sc.exe failureflag — re-run install.ps1 or run the two sc.exe lines from §7 manually on older installs). If it does not come back: Start-Service manually and check network reachability between nodes. |Also note next to the "auto-restart on failure" mention (line 87) that failureflag is part of the contract.- Verify:
grep -n "failureflag" deploy/wonder-app-vd03/install.ps1(3 effective invocations via the loop → 1 hit inside it) andgrep -n "failureflag\|self-down" deploy/wonder-app-vd03/RUNBOOK.md(≥1 hit). Ifpwshis installed, syntax-check:pwsh -NoProfile -Command "[void][System.Management.Automation.PSParser]::Tokenize((Get-Content -Raw deploy/wonder-app-vd03/install.ps1), [ref]$null)"(exit 0); otherwise the greps suffice. - No git commit (gitignored artifact — same rule as Task 5).
Task 7: Correct the factually wrong N2 deferral record in the master tracker
Classification: trivial (doc-only) Estimated implement time: ~3 min Parallelizable with: Task 1, Task 2, Task 4, Task 8, Task 9, Task 10, Task 11 Files:
- Modify:
archreview/plans/00-MASTER-TRACKER.md(line 28 trailing sentence; "Deferred during Wave 1 PLAN-01 execution (2026-07-08)" bullet at lines 145-147)
The deferral rationale — "that production deploy artifact is not tracked in this repo … the artifact directory itself is not present in the repo" — is factually wrong (round-2 N2): deploy/wonder-app-vd03/ exists in the working tree, is merely gitignored (.gitignore:48: /deploy/), and was edited during this initiative (PLAN-07's _comment_Security). Depends on Tasks 5-6 having applied the edits.
- Rewrite the line-147 bullet: keep the history (what was deferred and why it mattered), then correct the record: the directory is present but gitignored (production config kept out of version control — the tracking claim was wrong, the presence claim was false); the three overlay edits were applied on-disk 2026-07-12 by PLAN-R2-01 Tasks 5-6 (
NodeNamecentral-a/node-a,AllowSingleNodeCluster+ phantom seeds dropped,sc.exe failureflagcompleting the already-presentsc.exe failureactions). Remaining owner action: sync the artifact towonder-app-vd03and restart services (appsettings take effect on restart; failureflag on re-running install.ps1 §7 or the twosc.exelines manually) — per the RUNBOOK row added by T6. - Trim the stale trailing sentence of the line-28 Wave-1 summary ("were deferred because that production artifact is not tracked in this repo") → "were deferred on a mistaken not-in-repo rationale; corrected and applied on-disk by PLAN-R2-01 (2026-07-12) — see the Deferred-during-Wave-1 entry."
- Do NOT add rows to
docs/plans/2026-07-08-deferred-work-register.md— register consolidation (round-2 N6) is owned by PLAN-R2-08; this task only stops the tracker from asserting a falsehood. - Verify:
grep -n "not tracked in this repo" archreview/plans/00-MASTER-TRACKER.md→ 0 hits;grep -n "PLAN-R2-01" archreview/plans/00-MASTER-TRACKER.md→ ≥2 hits. - Commit:
git add archreview/plans/00-MASTER-TRACKER.md
git commit -m "docs(tracker): correct the wonder-app-vd03 deferral record — artifact is present (gitignored); overlay edits applied on-disk by R2-01 T5/T6 (plan R2-01 T7)"
Task 8: Metrics-staleness for never-reported sites (FirstSeenAt anchor)
Classification: high-risk (lock-free CAS state machine) Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7, Task 9, Task 10, Task 11 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs(addFirstSeenAt) - Modify:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs(register branches lines 44-54 and 114-122; staleness guard lines 300-312) - Modify:
docs/requirements/Component-HealthMonitoring.md("Metrics staleness" bullet, line ~47) - Test:
tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs
Round-2 N3: CheckForOfflineSites guards staleness with state.LastReportReceivedAt is { } lastReport (CentralHealthAggregator.cs:300), so a site registered via heartbeat only (MarkHeartbeat registration leaves LastReportReceivedAt null, :114-122) is NEVER flagged stale — "stuck from first boot" shows online-with-no-metrics forever. LastHeartbeatAt cannot be the anchor (heartbeats keep advancing it); anchor on a new FirstSeenAt stamped at registration. No UI change needed: the badge (Health.razor:209-215) keys on IsOnline && IsMetricsStale regardless of LatestReport.
- Write the failing tests (append to
CentralHealthAggregatorTests— reuse the existingNewAggregator(metricsStaleTimeout:)andTestTimeProvider.Advancehelpers, lines 48-59/22):
/// <summary>
/// Round-2 N3: a site known only via heartbeats (LastReportReceivedAt == null)
/// previously skipped the staleness check forever — a report pipeline dead
/// from FIRST BOOT showed "online with no metrics" indefinitely. FirstSeenAt
/// anchors the window instead (LastHeartbeatAt cannot: heartbeats advance it).
/// </summary>
[Fact]
public void HeartbeatOnlySite_NeverReported_IsFlaggedMetricsStale()
{
var (aggregator, time) = NewAggregator(metricsStaleTimeout: TimeSpan.FromMinutes(2));
aggregator.MarkHeartbeat("site-a", time.GetUtcNow()); // registered via heartbeat only
time.Advance(TimeSpan.FromMinutes(3));
aggregator.MarkHeartbeat("site-a", time.GetUtcNow()); // still heartbeating => online
aggregator.CheckForOfflineSites();
var state = aggregator.GetSiteState("site-a")!;
Assert.True(state.IsOnline); // liveness unchanged
Assert.True(state.IsMetricsStale); // was impossible before the fix
}
/// <summary>A heartbeat-only site inside the window is NOT falsely flagged.</summary>
[Fact]
public void HeartbeatOnlySite_WithinStaleWindow_IsNotFlagged()
{
var (aggregator, time) = NewAggregator(metricsStaleTimeout: TimeSpan.FromMinutes(2));
aggregator.MarkHeartbeat("site-a", time.GetUtcNow());
time.Advance(TimeSpan.FromMinutes(1));
aggregator.MarkHeartbeat("site-a", time.GetUtcNow());
aggregator.CheckForOfflineSites();
Assert.False(aggregator.GetSiteState("site-a")!.IsMetricsStale);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests --filter "FullyQualifiedName~HeartbeatOnlySite"— expect the first test to FAIL (IsMetricsStalestays false; the null guard skips the site). - Implement:
SiteHealthState: add
/// <summary>
/// Gets the instant this site was first observed by the aggregator (via a
/// report or a heartbeat); <c>null</c> only for states not built by the
/// aggregator (hand-constructed fixtures). Anchors metrics-staleness for a
/// site that has NEVER delivered a report (review 01 round-2 N3): with
/// <see cref="LastReportReceivedAt"/> null the staleness check previously
/// skipped the site forever. <see cref="LastHeartbeatAt"/> cannot anchor
/// this — every heartbeat advances it.
/// </summary>
public DateTimeOffset? FirstSeenAt { get; init; }
CentralHealthAggregator.ProcessReportregister branch (lines 44-54): addFirstSeenAt = now,.MarkHeartbeatregister branch (lines 114-122): addFirstSeenAt = receivedAt,. (Update branches never touch it —withcopies preserve it.)CheckForOfflineSitesstaleness guard (lines 300-312): replace thestate.LastReportReceivedAt is { } lastReportcondition with an anchor fallback:
// Second signal: … (keep the existing comment, append:)
// A site that has NEVER reported (heartbeat-only registration,
// LastReportReceivedAt == null) anchors on FirstSeenAt instead —
// otherwise a pipeline dead from first boot is never flagged (N3).
var reportAnchor = state.LastReportReceivedAt ?? state.FirstSeenAt;
if (reportAnchor is { } anchor
&& now - anchor > _options.MetricsStaleTimeout
&& !state.IsMetricsStale)
{
var stale = state with { IsMetricsStale = true };
if (_siteStates.TryUpdate(kvp.Key, stale, state))
{
_logger.LogWarning(
"Site {SiteId} metrics are stale — online (heartbeats) but no report for {Elapsed}s{NeverNote} (timeout: {Timeout}s)",
state.SiteId, (now - anchor).TotalSeconds,
state.LastReportReceivedAt is null ? " (never reported since first contact)" : string.Empty,
_options.MetricsStaleTimeout.TotalSeconds);
}
// CAS loss ⇒ a fresh report/heartbeat swapped in ⇒ correct to skip.
}
Component-HealthMonitoring.md"Metrics staleness" bullet (line ~47): append — "A site that has never delivered a report is anchored on its first-contact time (FirstSeenAt), so a report pipeline dead from first boot is also flagged afterMetricsStaleTimeout(previously such a site showed online-with-no-metrics indefinitely)."
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests— full project green (the CAS/flap/stale suites are the regression net). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs docs/requirements/Component-HealthMonitoring.md tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs
git commit -m "fix(health): flag metrics-stale for never-reported sites via FirstSeenAt anchor — null LastReportReceivedAt no longer skips the check (plan R2-01 T8)"
Task 9: Purge the stale "cluster leader" narration from CentralHealthReportLoop
Classification: trivial (comment-only) Estimated implement time: ~2 min Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7, Task 8, Task 10, Task 11 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs(class doc line 12-14; sequence-seed comment lines 44-45)
Round-2 N4: the behavior is correct (line 93 gates on IClusterNodeProvider.SelfIsPrimary = oldest Up member since PLAN-01 T6), but the comments still narrate "cluster leader (Primary)" — re-teaching the exact leader/oldest conflation round-1 S2 eradicated.
- Confirm scope first:
grep -in "leader" src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs— expect exactly lines 12, 44, 45. - Edit:
- Class doc (lines 12-14): "Only the cluster leader (Primary) generates reports — the standby's aggregator catches up on failover when it becomes Primary and starts its own loop." → "Only the active node (Primary = the oldest Up member, i.e. the singleton host —
IClusterNodeProvider.SelfIsPrimary, never Akka cluster leadership) generates reports; the standby's aggregator catches up on failover when it becomes the oldest and its loop starts reporting." - Sequence-seed comment (lines 44-45): "reports from a newly-elected central leader always sort after reports from any prior leader" → "reports from a node that has newly become active (oldest Up member) always sort after reports from any previously-active node".
- Class doc (lines 12-14): "Only the cluster leader (Primary) generates reports — the standby's aggregator catches up on failover when it becomes Primary and starts its own loop." → "Only the active node (Primary = the oldest Up member, i.e. the singleton host —
- Verify:
grep -in "leader" src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs— 0 hits. Comment-only change:dotnet build src/ZB.MOM.WW.ScadaBridge.HealthMonitoring— succeeds. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs
git commit -m "docs(health): CentralHealthReportLoop comments say oldest-member, not cluster leader — stop re-teaching the S2 conflation (plan R2-01 T9)"
Task 10: Generalize the registrar — SingletonRegistrar with an optional role scope
Classification: high-risk (actor lifecycle helper used by all central singletons) Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7, Task 8, Task 9 Files:
- Modify (rename):
src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs→src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs(usegit mv) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs(rename at the block comment lines 459-465 and the 7 central call sites: lines 466, 499, 564, 609, 632, 663, 689) - Modify (rename):
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs→tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs - Modify:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs(line 56 source-grep string)
Round-2 N5 first half: CentralSingletonRegistrar cannot express .WithRole(siteRole), which is the sole reason the two site singletons stay hand-rolled without drains (AkkaHostedService.cs:464-465). Add an optional role applied to BOTH the manager and proxy settings, and rename the class — "Central" becomes a lie the moment it hosts site singletons.
- Write the failing test — in the (renamed)
SingletonRegistrarTests.cs, rename the class + existing referencesCentralSingletonRegistrar→SingletonRegistrar, and add:
[Fact]
public async Task Start_WithRoleScope_CreatesRoleScopedSingleton_ThatAnswers()
{
// Round-2 N5: role scoping is what kept the two SITE singletons
// (deployment-manager, event-log-handler) hand-rolled and drain-less.
using var system = CreateSingleNodeClusterSystem(); // roles = ["Central"]
var handle = SingletonRegistrar.Start(
system, "role-widget", Props.Create(() => new EchoActor()),
NullLogger.Instance, drainTimeout: TimeSpan.FromSeconds(2), role: "Central");
Assert.EndsWith("/user/role-widget-singleton", handle.Manager.Path.ToString());
Assert.EndsWith("/user/role-widget-proxy", handle.Proxy.Path.ToString());
// The node holds the role => manager instantiates the singleton and
// the role-scoped proxy resolves it.
var echo = await handle.Proxy.Ask<string>("hi", TimeSpan.FromSeconds(20));
Assert.Equal("hi", echo);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~SingletonRegistrarTests"— expect FAIL (compile: noSingletonRegistrartype / noroleparameter). - Implement:
git mv src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs; rename the class; signature →Start(ActorSystem system, string name, Props singletonProps, ILogger logger, TimeSpan? drainTimeout = null, string? role = null); apply the role to both settings:
var managerSettings = ClusterSingletonManagerSettings.Create(system).WithSingletonName(name);
if (role is not null) managerSettings = managerSettings.WithRole(role);
// … ClusterSingletonManager.Props(singletonProps, PoisonPill.Instance, managerSettings) …
var proxySettings = ClusterSingletonProxySettings.Create(system).WithSingletonName(name);
if (role is not null) proxySettings = proxySettings.WithRole(role);
// … ClusterSingletonProxy.Props($"/user/{name}-singleton", proxySettings) …
Update the class XML doc: drop "central"-only framing; drain rationale now reads "so in-flight EF (central) or SQLite (site) work completes before handover"; note the optional role maps to ClusterSingletonManagerSettings/ProxySettings.WithRole (review 01 round-2 N5).
AkkaHostedService.cs: mechanical rename at the 7 call sites (466, 499, 564, 609, 632, 663, 689) and rewrite the lines-459-465 comment's last sentence ("The two SITE singletons stay hand-rolled below because they are role-scoped") → "Site singletons use the same registrar withrole: siteRole(Task 11 / round-2 N5)." (Task 11 lands that; wording here is forward-referencing but true within the same plan.)git mv tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs.CoordinatedShutdownTests.csline 56:Assert.Contains("CentralSingletonRegistrar.Start(", content);→Assert.Contains("SingletonRegistrar.Start(", content);(also update the comment above it).- Sanity:
grep -rn "CentralSingletonRegistrar" src/ tests/ --include="*.cs"— 0 hits.
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~SingletonRegistrarTests|FullyQualifiedName~CoordinatedShutdownTests"— PASS. Thendotnet build ZB.MOM.WW.ScadaBridge.slnx— clean. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs
git rm --cached src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs 2>/dev/null || true
git commit -m "refactor(host): CentralSingletonRegistrar -> SingletonRegistrar with optional role scope — unblocks site-singleton drains (plan R2-01 T10)"
(git mv already stages the renames; the git rm --cached … || true line is a no-op safety net if the moves were done as delete+create.)
Task 11: Route the two site singletons through the registrar — deployment-manager + event-log-handler gain drains
Classification: high-risk (site actor wiring refactor) Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7, Task 8, Task 9 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs(site branch: deployment-manager block lines ~791-820; event-log-handler block lines ~836-856) - Test:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs
Round-2 N5 second half: deployment-manager and event-log-handler are hand-rolled with bare PoisonPill termination and no PhaseClusterLeave drain. The round-1 S5 rationale applies identically on the site side: the DeploymentManager's SQLite writes (static overrides, native_alarm_state) should drain before handover — currently safe-but-lossy on graceful failover.
- Write the failing test — in
CoordinatedShutdownTests.cs, update the existingAllCentralSingletons_RegisterThroughRegistrarWithDrain(lines 45-70): change the final assertionAssert.Equal(2, CountOccurrences(content, "ClusterSingletonManager.Props("));→Assert.Equal(0, …)and replace its "the only two left are the SITE singletons" comment; then add:
[Fact]
public void SiteSingletons_RegisterThroughRegistrarWithDrain()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var content = File.ReadAllText(Path.Combine(hostProjectDir!, "Actors", "AkkaHostedService.cs"));
// Round-2 N5: the two role-scoped SITE singletons previously stayed
// hand-rolled (bare PoisonPill, no PhaseClusterLeave drain). They now
// go through SingletonRegistrar.Start(..., role: siteRole), which
// always adds the GracefulStop drain — in-flight SQLite writes
// (static overrides, native_alarm_state) complete before handover.
Assert.Contains("\"deployment-manager\"", content);
Assert.Contains("\"event-log-handler\"", content);
Assert.Contains("role: siteRole", content);
Assert.Equal(0, CountOccurrences(content, "ClusterSingletonManager.Props("));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~SiteSingletons_RegisterThroughRegistrarWithDrain"— expect FAIL (ClusterSingletonManager.Props(count is 2; norole: siteRole). - Implement in the site branch of
AkkaHostedService.cs:- Deployment-manager block (~791-820): replace the hand-rolled
ClusterSingletonManager.Props/ClusterSingletonProxy.Propspair with
- Deployment-manager block (~791-820): replace the hand-rolled
// Deployment Manager — role-scoped singleton via SingletonRegistrar
// (review 01 round-2 N5): previously hand-rolled with bare PoisonPill
// termination and NO PhaseClusterLeave drain, so in-flight SQLite
// writes (static overrides, native_alarm_state) could be cut off on
// graceful failover. Names unchanged: deployment-manager-singleton /
// deployment-manager-proxy.
var dm = SingletonRegistrar.Start(
_actorSystem!, "deployment-manager",
Props.Create(() => new DeploymentManagerActor(
storage, compilationService, sharedScriptLibrary, streamManager,
siteRuntimeOptionsValue, dmLogger, dclManager, replicationActor,
siteHealthCollector, _serviceProvider, null, deploymentConfigFetcher)),
_logger, role: siteRole);
var dmProxy = dm.Proxy;
(Keep the dmProxy variable name — everything downstream, SiteCommunicationActor construction and RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy), is untouched.)
- Event-log-handler block (~836-856): inside the existing
if (eventLogQueryService != null):
var eventLog = SingletonRegistrar.Start(
_actorSystem, "event-log-handler",
Props.Create(() => new SiteEventLogging.EventLogHandlerActor(eventLogQueryService)),
_logger, role: siteRole);
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.EventLog, eventLog.Proxy));
(Keep the existing "Event log handler — cluster singleton so queries always reach the active node…" comment above it.)
- Actor/singleton names are reproduced exactly by the registrar (
deployment-manager-singleton/-proxy,event-log-handler-singleton/-proxy), so path-pinning tests and the ClusterClient receptionist wiring see no change.
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests(full project — path/health-check/audit-wiring suites are the regression net) anddotnet build ZB.MOM.WW.ScadaBridge.slnx— PASS/clean. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs
git commit -m "fix(host): site singletons (deployment-manager, event-log-handler) via SingletonRegistrar — PhaseClusterLeave drains for site SQLite writes (plan R2-01 T11)"
Dependencies on other plans
- PLAN-R2-02 owns the oldest-vs-leader predicate unification inside
SiteCommunicationActor/SiteReplicationActorand everything resync-related. This plan does not touch those files; the coverage table references it. - PLAN-R2-08 owns N6 (folding the two live PLAN-01 deferrals — the keep-oldest active-crash gap and, post-T5/T6, the "sync the artifact to the host" residue — into
docs/plans/2026-07-08-deferred-work-register.md). Task 7 here only corrects the factually wrong tracker prose; it adds no register rows. - The SBR keep-oldest active-crash availability gap stays a registered deferred USER decision — no task here changes the strategy/topology; Tasks 1-3 make the gap observable and measured, which is the input that decision needs.
Execution order
Drill spine (serial): Task 1 (script) → Task 2 (docs) → Task 3 (live run, needs the docker cluster exclusively).
Registrar spine (serial): Task 10 → Task 11.
Freely parallel at any point: Tasks 4, 5, 6, 8, 9 (all file-disjoint); Task 7 after 5+6.
Highest value if time-boxed: Task 3 (the never-run drill, run at last) + Task 11 (real durability gap) + Task 8 (real monitoring blind spot).
Findings Coverage
Every round-2 finding (N1-N6), the round-1 partials the report says warrant completion, and the cross-plan rulings:
| # | Round-2 finding | Severity | Task(s) / Disposition |
|---|---|---|---|
| N1 | Failover drill kills the wrong (active/oldest) node; first-seed bootstrap gap means the survivor's recovery story doesn't converge; drill never executed; ~25s promise undeliverable in its most likely case | High | Tasks 1 (drill rewrite: standby-victim default + explicit active-gap mode), 2 (narrative + first-seed constraint documented with operator actions — deliberately NOT reordered to self-first seeds: simultaneous cold-start self-joins would risk two independent clusters), 3 (RUN the drill live, both directions, record measured timings), 4 (wire/unskip FailoverTimingTests to TwoNodeClusterFixture, measure the ~25s envelope at production timings — also covers report-08 NF2 by reference). The keep-oldest topology/strategy remedy itself stays deferred: registered USER decision (master tracker 2026-07-08) — Tasks 1-3 document/measure it, never fix it |
| N2 | wonder-app-vd03 overlay edits unapplied; deferral rationale factually wrong (artifact IS in the working tree, merely gitignored) | Medium | Tasks 5 (NodeName + AllowSingleNodeCluster + drop phantom seeds, both overlays), 6 (sc.exe failureflag completing the recovery actions + RUNBOOK step — note: sc.exe failure itself is already on disk, contra the report), 7 (correct the deferral record in the master tracker). No git commits for the gitignored artifact itself |
| N3 | IsMetricsStale can never fire for a site that has never delivered a report (null LastReportReceivedAt guard) |
Low | Task 8 (FirstSeenAt anchor; no UI change needed — badge already keys on the flag) |
| N4 | CentralHealthReportLoop still narrates "cluster leader (Primary)" |
Low | Task 9 |
| N5 | Site-role singletons lack drain tasks; registrar can't express role scoping | Low | Tasks 10 (role param + rename to SingletonRegistrar), 11 (route deployment-manager + event-log-handler through it) |
| N6 | PLAN-01's two live deferrals missing from the canonical deferred-work register | Low | Reference → PLAN-R2-08 (register consolidation is its scope). Task 7 here only fixes the falsehood in the master-tracker prose |
| S12 (R1 partial) | Single-node artifact still ships the phantom seed, lacks AllowSingleNodeCluster |
— | Task 5 (the artifact half; the flag/validator half shipped in PLAN-01 T16) |
| U2 (R1 partial) | install.ps1 recovery actions deferred; recovery contract doesn't converge for a non-first-seed survivor | — | Task 6 (failureflag) + Task 2 (first-seed constraint + operator actions in the spec'd recovery contract) |
| U5 (R1 not fixed) | NodeName missing from wonder-app-vd03 overlays → NULL SourceNode |
— | Task 5 |
| C3 (R1 partial) | LDAP Transport: None, AllowInsecure: true remains in the deploy artifact |
— | Stays accepted posture per the report — bundled loopback GLAuth with documented AD-repoint instructions; a future over-the-wire AD repoint must revisit transport. No task |
| — | Oldest-vs-leader predicate unification in SiteCommunicationActor/SiteReplicationActor + resync work |
— | Reference → PLAN-R2-02 (explicit cross-plan ownership; not touched here) |