56 KiB
Perf Follow-ups Round 3 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (per the standing preference: Opus implementers, classification-driven review chains).
Goal: Close the actionable items in the round-2 follow-up register
(docs/plans/2026-08-17-perf-followups-r2.md §"Follow-up register") and run the
still-unexercised local live gates (FetchAndCache legs, alarm-bearing surgical ratchet,
ACL-enabled enforcement, docker-dev first-POST-404 catch), on branch perf/followups-r3
off master merge 49d05bc2.
Architecture: Two surgical code fixes in already-reviewed code (the ACL cache's single-shot CAS becomes a monotonic-max loop; S7 read-pass health becomes worst-state-wins aggregation), one doc-staleness fix found during exploration, and four live gates on the local docker-dev rig (OrbStack) sequenced on one rig session. No new subsystems.
Tech Stack: .NET 10 / C#, xUnit + Shouldly, docker-dev rig (OrbStack), Traefik v3.1 access log, Client.CLI, Chrome browser automation for AdminUI authoring.
Out of scope (recorded, deliberately not here):
- TM/CT decode at a non-zero device number — needs a real S7 CPU; the test exists and
is env-gated on
S7_TIMER_NUMBER_ADDRESSING=1. Nothing to do until hardware exists. - Cross-process plant-vs-reap race on the shared SQL server — accepted in r2; the 24 h age floor is the mitigation. No work.
- Modbus per-tag health last-writer-wins — Task 2 fixes S7 only (the recorded item).
Modbus has the same shape (
ModbusDriver.ReadAsync:493/511/518+ the coalesced path); fixing it here doubles the blast radius of a watchdog-adjacent change. Recorded in this round's register as a deliberate asymmetry. - S7 write-path per-item health (
S7Driver.cs:1575/1633/1645/1654) — same last-writer-wins shape onWriteAsync; out of scope for the same reason, recorded. - Historian end-to-end live gate — needs the
wonder-sql-vd03VPN (user-driven). - HistorianGateway batch
WriteLiveValuesRPC — cross-repo. - Load-scale throughput — needs a load harness; not defined enough to gate.
Execution rules (unchanged from prior rounds): shared checkout — explicit-pathspec
git add/git commit -- <files> only (never a bare commit); never checkout/switch/reset/
stash/clean; retry on index.lock and CS2012/MSB3030 build races; reviewers judge commits
via git show; falsification mutations applied AND reverted via Edit (never git), run
against a freshly built binary (never --no-build after a mutation), and labeled honestly
("pin, not red-first" where the behaviour predates the test; "probabilistic red" where the
mutation trips a race). Live-gate rig work is serial — one rig session, Tasks 4→5→6→7 in
that order, images built from the branch tip AFTER Tasks 1–3 land so the gates also
regression the new code.
Task 1: ACL InvalidateIfStale — monotonic-max CAS loop (publication never deferred)
Classification: high-risk Estimated implement time: ~4 min Parallelizable with: Tasks 2, 3
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs:453-461(+ the XML remarks block:430-452) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
Context. The r2 register records: the single-shot CAS in InvalidateIfStale can defer
stamp publication by one call. Two callers read the same seen baseline; gen-N wins the
CAS, gen-N+1's CAS fails against the moved stamp and falls out with no retry — N+1 is
not published on that call, so its TryGet misses and its Set is dropped at :223.
Benign (verdict safety lives in the per-shard generation gate), but it costs a lost store
exactly in the post-deploy window. Current code:
private void InvalidateIfStale(long generation)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return;
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) ClearAll();
}
Step 1: Write the racing pin (red against current code — probabilistic red, say so).
[Fact]
public void Concurrent_lookups_at_mixed_generations_always_publish_the_highest()
{
// The single-shot CAS defect: when gen-1 wins the shared-baseline race, gen-2's CAS
// fails and is never retried, leaving Generation == 1. Red is probabilistic per
// round; 200 barrier-synchronised rounds make it effectively certain.
for (var round = 0; round < 200; round++)
{
var cache = /* fresh cache, generation seed -1 */;
using var barrier = new Barrier(2);
var low = Task.Run(() => { barrier.SignalAndWait(); /* TryGet at generation 1 */ });
var high = Task.Run(() => { barrier.SignalAndWait(); /* TryGet at generation 2 */ });
Task.WaitAll(low, high);
cache.Generation.ShouldBe(2L,
$"round {round}: the newer generation must publish even when it loses the CAS race");
}
}
Use the ordinary TryGet lookup path (as the straggler test at :286 does), NOT
AdvanceGenerationForTest — the defect lives in the lookup path's invalidation.
Step 2: Run it, observe red. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Concurrent_lookups_at_mixed_generations" — expect FAIL within a few rounds. If 200 rounds pass green against unmodified code, the test is not exercising the race — fix the test, do not proceed.
Step 3: Implement the monotonic-max loop.
private void InvalidateIfStale(long generation)
{
while (true)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return; // guard re-checked per attempt
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen)
{
ClearAll();
return;
}
}
}
Invariants (update the XML remarks :430-452 to say all of this):
- The
<=guard is INSIDE the loop against a fresh read — a straggler that lost a CAS re-reads the newer stamp and exits; hoisting the guard reintroduces the98d6a190defect. - Only the thread whose CAS moved the stamp clears (elected clear preserved). A rollover now legitimately clears once per distinct generation (N then N+1) — bounded by generations, not request volume.
- The loop self-terminates: each iteration exits via the guard or CASes against a strictly
larger
seen, and_generationonly rises. - The
-1seed still admits generation 0.
Step 4: Run the new test + the full cache suite. All 18 green (17 existing + 1 new),
especially A_straggler_from_an_older_generation_neither_clears_nor_rolls_the_stamp_back
(:286) and No_hit_ever_returns_a_verdict_from_another_generation (:363).
Step 5: Falsify. Via Edit, revert the loop to the single-shot form (fresh build, not
--no-build) → the new test must go red (probabilistic; report the failing round). Revert
via Edit. Separately, move the <= guard outside the loop → the straggler test at :286
must go red. Revert.
Step 6: Commit.
git add -- src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
git commit -m "fix(acl): make InvalidateIfStale a monotonic-max CAS loop — stamp publication is never deferred" -- <same paths>
Task 2: S7 read-pass health — worst-state-wins aggregation
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Tasks 1, 3
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs— per-item health sites:647(per-tag loop),:828(multi-var decode loop),:941(span member loop),RecordReadFailure:1010-1046, plus one end-of-pass write inReadAsync - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs(flip the:423pin; doc-comment:389-393rewritten; new tests)
Context. The r2 register records: per-item decode health is last-writer-wins — a mixed
chunk reports whatever the LAST item's outcome was, so Healthy can be reported for a
pass that had failures (pinned at :396-423), and the :1023 access-denied Faulted can
be overwritten by a later good item. Fix = aggregate one outcome per ReadAsync pass.
Watchdog contract (MUST hold — CLAUDE.md comms-loss section): Degraded means
connected-but-some-bad; LastSuccessfulRead advances on ANY success and is preserved on
failure. The aggregate write must advance LastSuccessfulRead when ANY item succeeded,
even when the state lands Degraded/Faulted — gating it on Healthy turns the fix into
a false-positive reconnect storm.
Step 1: Red tests first (the flip IS red-first; the rest are new):
- Flip
:423:One_undecodable_multi_var_item...now expectsDegraded(rename to say worst-state-wins; rewrite doc-comment:389-393— the last-writer-wins pin is being deliberately retired). - New: a mixed multi-var pass reports
DegradedANDLastSuccessfulReadis advanced (non-null / newer than the pre-pass value). - New: span path — one bad member decode then a good member ⇒
Degraded(today: Healthy). - New: an access-denied
PlcExceptionon one tag followed by a success ⇒ state staysFaulted(orderingHealthy < Degraded < Faulted),LastSuccessfulReadadvanced. Use the per-tag path if the multi-var fake can't produce a per-item PlcException. - Must-stay-green:
:326(A_failing_multi_var_chunk_falls_back..., asserts Healthy at:346) and:432(short answer, Healthy at:447) — a multi-var REQUEST fault whose tags are re-served Good by the per-tag tail is NOT a failed outcome. The chunk-fault catch (:806-814) stays outside the aggregate.
Step 2: Run, observe red on 1–4 (each for its own reason; report per-test).
Step 3: Implement. A small per-pass accumulator threaded through the pass:
private sealed class ReadPassOutcome
{
public bool AnySuccess;
public DriverState WorstFailure = DriverState.Healthy; // Healthy = none recorded
public string? FirstError; // first failure is causal
public void RecordSuccess() => AnySuccess = true;
public void RecordFailure(DriverState state, string message)
{
if (Rank(state) > Rank(WorstFailure)) WorstFailure = state;
FirstError ??= message;
static int Rank(DriverState s) => s switch
{
DriverState.Faulted => 2, DriverState.Degraded => 1, _ => 0
};
}
}
- The three success sites (
:647,:828,:941) calloutcome.RecordSuccess()instead of assigning_health. RecordReadFailuretakes the outcome, records(Degraded|Faulted, message)into it instead of assigning_health, and KEEPS its_plcDead/MarkConnectionDeadIfFatalside effects immediate. It still returns the per-item status code.- One
_healthwrite at the end of theReadAsynctry body (NOTfinally— the OCE arms at:649/:801/:920rethrow and must skip it):
_health = new DriverHealth(
outcome.WorstFailure == DriverState.Healthy
? DriverState.Healthy : outcome.WorstFailure,
outcome.AnySuccess ? now : _health.LastSuccessfulRead,
outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError);
- Untouched: the
:567connect-failure early return;HandlePollError(:1980); the entire write path; init/shutdown sites.ReadSpanAsync/ReadLeftoversByMultiVarAsyncgain an outcome parameter — signature changes, no logic movement.
Step 4: Run the full S7 suite (dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests)
— all green including the parity anchor :202 (all-members-fail ⇒ Degraded both paths).
Step 5: Falsify. Via Edit: (a) make the end-of-pass write use the LAST recorded state
instead of the worst → tests 1/3/4 red; (b) gate LastSuccessfulRead advancement on the
state being Healthy → test 2 red. Fresh builds. Revert via Edit.
Step 6: Commit (explicit pathspec, message
fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy).
Task 3: Fix stale OTOPCUA_CONFIG_MODE docs
Classification: trivial Estimated implement time: ~2 min Parallelizable with: Tasks 1, 2
Files:
- Modify:
docs/Configuration.md(~line 253) — "Flip only the site nodes withOTOPCUA_CONFIG_MODE=FetchAndCacheatdocker compose up" is stale - Modify:
CLAUDE.md— same sentence in theConfigSource/ConfigServesection - Check-and-fix if matched:
grep -rn "OTOPCUA_CONFIG_MODE" docs/ docker-dev/ CLAUDE.md
Spec. Phase 4 made FetchAndCache mandatory on driver-only nodes
(ConfigSourceOptionsValidator fails host start on Direct), and the rig's four site
nodes are hardcoded ConfigSource__Mode: "FetchAndCache" in
docker-dev/docker-compose.yml:572-579/691-696/787-792/858-863. The env var appears
nowhere in the compose file; setting it is a no-op. Rewrite both sentences to state: the
site nodes run FetchAndCache unconditionally (mandatory for driver-only nodes since
Phase 4); central stays Direct via its admin role. No review (doc-only). Commit with
explicit pathspec, message docs(config): OTOPCUA_CONFIG_MODE is gone — site nodes are hardcoded FetchAndCache (Phase 4).
Task 4: Live gate — docker-dev first-POST-404 catch + no-body POST binding
Classification: live-gate (rig session step 1 — FRESH BOOT window) Estimated implement time: ~10 min wall (mostly rig boot) Parallelizable with: none (rig serial; runs after Tasks 1–3 land, images built from branch tip)
Files:
- Possibly modify:
docker-dev/deploy.sh(the header NOTE about no-body binding),docker-dev/README.md(§"First-POST 404" — mechanism note if caught) - Evidence: write to the scratchpad; Task 8 folds it into the plan's Outcomes
Procedure:
- Fresh rig:
docker compose -f docker-dev/docker-compose.yml down -vthenup -d --build(build mandatory — the branch tip must be in the images). - IMMEDIATELY run
./docker-dev/deploy.sh(no body). Its 2 s/health/readypoll rides through Traefik during the boot window — every poll is access-logged withdefaultmode=keep, and an unmatched request shows an EMPTYRouterName. - After the deploy completes (or fails), capture
docker compose -f docker-dev/docker-compose.yml logs traefikand extract every non-2xx access-log line from the boot window. If any 404 appears: name the mechanism fromRouterName/ServiceName/timing (the r2 elimination says: AdminUI cannot 404 that path; backend-down is 503; so 404 = no router matched at that instant — presumably the window before the dynamic-config file provider loads or before the health-checked service has a server). Updatedocker-dev/README.md§"First-POST 404" with the observed mechanism. If it does not reproduce: record that honestly — "did not reproduce under the readiness gate; register item stays open" — do NOT invent a mechanism. - No-body POST binding: with the rig ready, run
./docker-dev/deploy.shbare and record the HTTP status + response body. Determine whether the minimal-APIDeployRequest?binds null (and what the endpoint then does) or 400s. Update the header NOTE indeploy.shfrom "unverified" to the observed behaviour, andREADME.mdif wrong. - Leave the rig UP for Task 5.
Commit any deploy.sh/README.md edits (explicit pathspec). Report: the access-log
excerpt, the no-body verdict, and whether the 404 reproduced.
Task 5: Live gate — FetchAndCache legs on the running rig
Classification: live-gate (rig session step 2) Estimated implement time: ~8 min wall Parallelizable with: none (rig serial, after Task 4)
Procedure (rig already up from Task 4; a deploy already ran):
- Fetch + verify:
docker compose -f docker-dev/docker-compose.yml logs site-a-1 | grep -E "Fetched \+ verified artifact"— expectFetched + verified artifact for {DeploymentId} from http://central-… ({Bytes} bytes). - Served (central side):
logs central-1 central-2 | grep "Served artifact"—Served artifact for {DeploymentId} ({Bytes} bytes, {Chunks} chunks). - Applied-from-fetch + cached:
logs site-a-1 | grep -E "applied fetched deployment|cached applied deployment"— both lines must appear (thefetchedvariant, NOT the Directapplied deploymentform). - Boot-from-cache:
docker compose restart site-a-1, wait for boot, thenlogs site-a-1 | grep "FetchAndCache boot"— expectrestored served state for deployment {Id} (rev {Rev}) from the LocalDb pointer. - Absence proof: on site-a-1 logs, assert NO occurrence of
entering Stale,ConfigDb back, or the Direct-formapplied deployment {Id} (rev(withoutfetched). A FetchAndCache node must never reach Stale. - Evidence excerpts to the scratchpad for Task 8. Rig stays UP for Task 6.
No commit expected. If any leg fails, STOP and report — that is a real regression, not a gate formality.
Task 6: Live gate — alarm-bearing surgical ratchet
Classification: live-gate (rig session step 3) Estimated implement time: ~12 min wall Parallelizable with: none (rig serial, after Task 5)
Context. The surgical-deploys classifier routes an alarm-bearing raw-tag delta
(Alarm != null on EITHER side) to Rebuild; a whitelisted non-alarm attribute delta
(Writable among them) is surgical. Never observed live. The ONLY seam is the applier
summary line (AddressSpaceApplier.cs:322-326):
applied plan (kind={Kind}, …, surgicalRawTags={SurgicalRaw}, …, rebuild={Rebuild}).
Procedure:
- Pick TWO alarm-free raw tags in the rig's seeded config (browse
/rawvia AdminUI athttp://localhost:9200, login disabled — or read the ConfigDbTagrows). Call them TagS (surgical lever) and TagA (alarm lever). - Surgical control first: toggle TagS's Access level (Read ↔ ReadWrite) — via the
RawTagModal in the browser, or a direct ConfigDb UPDATE of the tag row (the rig's DB
on
10.100.0.35,14330; acceptable for a classifier gate, note which route was used). Deploy via./docker-dev/deploy.sh. Expect on central-1:kind=AttributeOnly, … surgicalRawTags=1, … rebuild=False. - Alarm add ⇒ rebuild: add
"alarm": {"alarmType": "OffNormalAlarm", "severity": 500}to TagA'sTagConfigJSON (raw-JSON textarea — available because the seeded device is a Galaxy driver, which has no typed editor — or the same direct-DB route). Deploy. Expectkind=Rebuild, … surgicalRawTags=0, … rebuild=True. - Alarm remove ⇒ rebuild: remove the
alarmobject, deploy, expectrebuild=Trueagain (Alarm != null on the PREVIOUS side also disqualifies). - Restore: revert TagS's access level, deploy once more (surgical again — also re-proves the ratchet is per-deploy, not latched).
- Evidence: the four applier lines (grep
docker compose logs central-1 site-a-1 | grep "applied plan (kind="— note which nodes applied). Scratchpad for Task 8. Rig stays UP for Task 7.
No commit expected. Failure of the expected routing = STOP and report.
Task 7: Live gate — ACL-enabled enforcement on site-a (re-shaped cache)
Classification: live-gate (rig session step 4) Estimated implement time: ~20 min wall Parallelizable with: none (rig serial, after Task 6)
Context. Security:Acl:Enabled has never been exercised live since the r1/r2 cache
re-shape (per-key shards, generation stamp, Task 1's CAS loop). Site-a is the env-gated
demo pair: OTOPCUA_ACL_ENABLED=true reaches site-a-1/-2 only
(docker-compose.yml:585/:702); site-b stays off as the A/B control. Site-a OPC UA:
host ports 4842/4843; site-b: 4844/4845. Keys/grants reach driver-only site nodes ONLY
via the deployment artifact — every authoring step needs a deploy after it.
Procedure:
- Restart site-a gated:
OTOPCUA_ACL_ENABLED=true docker compose -f docker-dev/docker-compose.yml up -d site-a-1 site-a-2. Confirm the boot Warning on both:node ACL enforcement is ENABLED — EVERY data-plane operation … requires a matching NodeAcl grant. - Mint a key: AdminUI
/apikeys(Chrome automation; login disabled) — Namer3-acl-gate, Principalr3gate. COPY the one-timeotua_…credential immediately. - Author a grant:
/clusters/{site-a cluster id}/acls/new— Principalr3gate, scope picked via the AclScopePickerModal on a WRITABLE raw tag (or its device — inheritance covers the tag), permissions: the Operator bundle (must include Browse/Read/ WriteOperate). Save; expect no "authorizes nobody" warning. - Deploy (
./docker-dev/deploy.sh) so the key + grant ride the artifact to site-a. Confirm on site-a-1:data-plane authority generation {G} installed (… keys=…, grants=…). - The load-bearing ALLOW: Client.CLI write through the grant —
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- write -u opc.tcp://localhost:4842 -n "<granted raw tag NodeId>" -v <value> -U apikey -P otua_…Expect success (the session carries NO server-wide role; the grant alone authorizes — a deny-only gate would pass against a build where the ACL is never consulted). Note: the rig's Galaxy driver has no live gateway, so the DEVICE write may fail downstream — the gate is that the request passes the ACL (notBadUserAccessDenied); any downstream Bad status is fine and should be reported as such. - Deny controls: (a) same write WITHOUT credentials (anonymous) ⇒ denied; (b) a
read/browse on a node OUTSIDE the granted scope with the key ⇒ withheld (grep
site-a-1 for
AclGate: denied {Operation} on {NodeId} for principal r3gate). - Revocation reaches the artifact path: disable the key on
/apikeys, deploy, confirm a NEWdata-plane authority generation {G+1} installedon site-a-1, and the same CLI write now fails auth (ApiKeyAuth: denied — no enabled, unexpired key). - Teardown: restore site-a ungated (
docker compose up -d site-a-1 site-a-2without the env), optionally delete the gate key/grant, anddocker compose downthe rig unless Task 8 needs it. Evidence excerpts to the scratchpad.
No commit expected. If Chrome automation of the scope picker proves impractical, direct
ConfigDb inserts for ApiKey/NodeAcl rows are acceptable ONLY as a fallback — say so,
and keep the key's peppered HMAC consistent with the rig pepper
(docker-dev-apikey-pepper-not-for-production); minting via the UI is strongly preferred
because it exercises the real authoring path.
Task 9: Roslyn evaluator racing test — remove the timing assumption
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10 (disjoint files); independent of the rig tasks
Origin: ScadaBridge deferred-work register row 38, relayed cross-session 2026-08-18 (coordinated by the ScadaBridge session on Joe's behalf).
Files:
- Modify:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs(Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed, ~:391 — renamed by the implementation toEvaluate_racing_ClearCompiledScripts_never_leaks_a_raw_disposed_failure)
Spec. The test is load-fragile: it fails under full-solution parallel test load but
passes 3/3 in isolation. Its design assumes at most ONE ClearCompiledScripts lands
inside a single evaluation (so the product's one-shot retry suffices); under contention
two can land and the retry legitimately exhausts. Test-design issue, NOT a product bug.
Fix by removing the timing assumption while keeping the original intent (no
disposed-script failure ever ESCAPES the retry): either bound the clear cadence relative
to observed evaluation latency, or assert on retry-exhaustion behaviour instead of zero
failures — implementer picks whichever preserves intent with less machinery, documents
the choice in the test doc-comment, and demonstrates the test survives a
full-parallel-load run (run the containing suite alongside another heavy suite, or
repeat-run the suite ≥5×; report what was actually run).
Task 10: LocalDb conditional registration + baseline + deregister self-heal
Classification: high-risk Estimated implement time: ~8 min Parallelizable with: Task 9 (disjoint files)
Origin: ScadaBridge deferred-work register row 39, relayed cross-session 2026-08-18.
ScadaBridge closed the same class with conditional registration + boot-time self-heal
(Host/SiteLocalDbSetup.cs, arch-review WP1.3/WP3.3); port that pattern here.
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs(OnReady, registration at :63-66) - Read for the predicate:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs(LocalDb:SyncListenPort,LocalDb:Replication:*keys) - Modify:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs(the registered-table-set + ordering pins — update DELIBERATELY, say so in the diff) - Modify:
CLAUDE.mdLocalDb section — the two bullets recording unconditional registration as deliberate ("DeregisterReplicated exists and this repo deliberately does not call it" / "Consequently, baselineExistingRows is not used either") must be rewritten to record the new conditional shape and why it changed.
Spec.
- "Replication configured" =
PeerAddressORApiKeypresent (OR, because only the dialling half sets PeerAddress while the passive half carries just the key + listen port — verify the exact key names againstLocalDbRegistration). - Configured ⇒
RegisterReplicated(table, baselineExistingRows: true)per table, in the existing DDL→register order (the ordering is load-bearing and pinned) — seeds row-versions at the LWW floor and flags snapshot resync, so late-enabled replication on an established node converges on EXISTING rows, not just subsequent writes. - NOT configured ⇒
DeregisterReplicatedon every table at boot — idempotent self-heal: drops stale triggers, prunes oplog/row-version rows, logs once at Information when it actually cleaned something (and stays silent when there was nothing to clean). - Registration stays per-table in a loop (0.3.0 made the snapshot flag per-table; the loop preserves that granularity if the pin ever moves).
- Pin note: this repo is on
ZB.MOM.WW.LocalDb 0.2.1, which has bothbaselineExistingRowsandDeregisterReplicated. Do NOT bump the pin unless a needed API is genuinely absent at 0.2.1 — if it is, stop and report before bumping. - Traps (both hit by ScadaBridge): the registered-table set is pinned by
LocalDbSetupTests— update the pins deliberately with the behaviour change named in the test doc-comment; and deregistration must be pair-symmetric — the sync handshake compares registered-table digests fail-closed, so flipping registration is a both-nodes-together change. Record the pair-symmetry consequence in CLAUDE.md and in the rollout note (docker-dev site-a has replication ON and was enabled from fresh, so current impact is low; the change is correctness-before-someone-enables-late). - Tests: pin the new predicate both ways (configured ⇒ registered with baseline; unconfigured ⇒ deregistered/self-healed) and keep the DDL→register ordering pin green.
Task 8: Close-out — sweep + Outcomes + register annotations
Classification: close-out (blockedBy: all) Estimated implement time: ~15 min
- Full build (
dotnet build ZB.MOM.WW.OtOpcUa.slnx) — 0 errors; warning count vs r2's 904 explained. - Full unit sweep, every
*.Testsproject, per-project; reconcile the passed-count delta against Tasks 1–2's added/changed tests exactly. DB suites must RUN (AdminUI0 skipped, Configuration0 skipped). - Write
## Outcomeson this plan: sweep record, per-task table, all four live-gate evidence excerpts, falsification notes, and this round's register (Modbus + S7-write asymmetry, TM/CT real-CPU, cross-process reap race carried; whatever Tasks 4–7 leave open). - Annotate the r2 register in
2026-08-17-perf-followups-r2.mdappend-only with "Closed by (r3)" / "Gated live (r3)" sub-bullets. - Update the
.tasks.jsonledger; commit docs with explicit pathspec.
Outcomes
Written at close-out, 2026-08-18, on branch perf/followups-r3. All 10 tasks complete
(8 planned + Tasks 9/10 joined mid-round by amendment 088a1a1e). This round is a
follow-up round: every code item it closes was opened by the round-2 register in
2026-08-17-perf-followups-r2.md, and that register is annotated in place with the closing
SHAs. Tasks 9 and 10 are cross-repo — rows 38 and 39 of the ScadaBridge deferred-work
register, relayed on Joe's behalf and closed here.
Sweep record
The docker-dev rig was torn down BEFORE the sweep (docker compose -f docker-dev/docker-compose.yml down, 0 otopcua-dev-* containers remaining). This is not
housekeeping — a running rig un-skips env-gated tests and would have made the skip count
incomparable to the r2 baseline. It did: Host.IntegrationTests reports 229 / 0 / 2 with
the rig up and 226 / 0 / 5 with it down, the same 231 total.
dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-incremental → 0 errors, 904 warnings —
identical to r2's 904, not merely close. The non-incremental flag matters: an incremental
build reported 896 because up-to-date projects re-emit nothing. The two pre-existing families
are unchanged in kind and in count (OTOPCUA0001 CapabilityInvoker analyzer on test call sites
1012, xUnit1051 cancellation-token advice 696, plus the small CS tail). No delta to
attribute — this round's new test call sites happened to land in files already carrying the
same families, so nothing moved.
Full unit sweep — every *.Tests project, run per-project with --no-build after the single
solution build, against the tree at 837d8a17. *.IntegrationTests are build-only by
convention (as in r2); Host.IntegrationTests is recorded separately below because r2's
baseline did not include it.
43 / 43 test projects green.
| Round 2 | This round | Δ | |
|---|---|---|---|
| Passed | 10 370 | 10 377 | +7 |
| Failed | 0 | 0 | — |
| Skipped | 19 | 19 | — |
| Total | 10 389 | 10 396 | +7 |
The +7 reconciles exactly against the two suites this round added tests to, with nothing unaccounted for — confirmed at class granularity, not just project granularity:
| Project | Round 2 | This round | Δ | Source |
|---|---|---|---|---|
OpcUaServer.Tests |
481 | 482 | +1 | SessionAclDecisionCacheTests 17 → 18 (T1) |
Driver.S7.Tests |
401 | 407 | +6 | S7SpanReadParityTests 13 → 19 (T2) |
⚠️ The S7 +6 is the sum of three separate commits, not one addition — 4c161181 (+3, the
worst-state-wins pins), b35e423c (+1, the observe-nothing guard the spec review found), and
1765e300 (+2, the code-review round's severity-escalation and OCE-flush pins). T3 (docs) and
T4–T7 (live gates) added no tests by design.
All 19 skips are the pre-existing env-gated ones, project for project — Core.Tests (1),
Driver.Galaxy.Tests (8), Driver.Historian.Gateway.Tests (6, the LiveIntegration category
needing the wonder-sql-vd03 VPN), OpcUaServer.Tests (1), Runtime.Tests (3). This round
added no skips, and Runtime.Tests is unchanged at 637 / 3 as expected.
The DB-backed suites ran against a reachable 10.100.0.35,14330 — executed, not skipped:
AdminUI.Tests 1540 passed / 0 skipped, Configuration.Tests 190 passed / 0 skipped.
Host.IntegrationTests (T9 + T10) — 226 passed / 0 failed / 5 skipped (231 total)
Not part of the r2 baseline, so there is no measured predecessor. Derived from the diff against
the merge-base 49d05bc2, the expected delta is +9, all plain [Fact] (no theories, so
attribute count equals test count): LocalDbSetupTests 7 → 15 (+8) and LocalDbWiringTests
6 → 7 (+1); RoslynVirtualTagEvaluatorTests stays 20 → 20 because T9 reworked its
racing test in place. ⚠️ The task brief's "LocalDbSetupTests 13 → 15" was an intermediate
reading — 13 was the count after 3e62f161 and before the df533916 review round; against
the merge-base it is 7 → 15. The 5 skips are the rig/fixture-gated
DriverTestConnectE2eTests (2, Modbus fixture) and DriverProbeHandshakeE2eTests (3) — three
of which run when the rig is up, which is exactly the teardown effect noted above.
⚠️ One flake, isolated and cleared — Client.CLI.Tests
The first sweep pass reported Client.CLI.Tests 1 failed / 103 passed:
LoggerLifecycleTests.ConfigureLogging_DisposesPreviousLogger_BeforeReassigning, asserting
trackerSink.Disposed was False. Isolation re-runs cleared it: the single test alone
1/1 green, and the whole project green twice more (104 / 104 both times).
The mechanism is a pre-existing intra-assembly race on the process-global
Serilog.Log.Logger static, not a regression: the test assigns Log.Logger at
LoggerLifecycleTests.cs:26 and then asserts, at :41, that the command's ConfigureLogging
disposed that instance — but the class carries no [Collection] and no
DisableTestParallelization, so any concurrently-scheduled test whose command path also calls
ConfigureLogging (which does Log.CloseAndFlush() then reassigns) can swap the global out
from under it in that window. No commit in this round touches src/Client or tests/Client
(git log 49d05bc2..HEAD -- src/Client tests/Client is empty). Recorded here rather than in
the register, which the close-out brief scoped explicitly; it is a worthwhile one-line fix
(pin the class to a serial collection) whenever someone is next in that file.
Per-task outcomes
| # | Task | Commit(s) | Outcome + deviation |
|---|---|---|---|
| 1 | ACL InvalidateIfStale — monotonic-max CAS loop |
441b34a3, f4b91dbb, e29fab19 |
Done; closes the r2 register's deferred-publication item. ⚠️ The plan's own 200-round Barrier harness was measured GREEN-ON-DEFECT (~1-in-19k hit rate — Barrier's post-phase release hands one racer a head start), so it was rebuilt as long-lived racers with a third-thread release store: 25–50 % of rounds red, falsification red at rounds 1–13. 10 000 rounds kept over the reviewer's suggested 2 000 after measuring a 1-in-3 vacuous pass at 2 000 under full-suite parallelism (misses burst-correlate). 18/18. |
| 2 | S7 read-pass health — worst-state-wins | 4c161181, b35e423c, 1765e300 |
Done; closes the r2 register's last-writer-wins item for the S7 READ path only (asymmetry recorded below). The spec review found a second defect in-round — an empty pass that observed nothing must report nothing, or it clears a live Faulted. The code review then found that the OCE arms skipped the end-of-pass write and froze LastSuccessfulRead; the coordinator ruled the OCE path must flush the recorded pass evidence via finally, which is what protects the watchdog contract. Five falsifications, each red on its own test. 19/19. |
| 3 | Stale OTOPCUA_CONFIG_MODE docs |
e9ba1741 |
Done as specified; compose facts verified before editing. Historical plan docs deliberately untouched. Trivial ⇒ no review. |
| 4 | Live gate — first-POST 404 + no-body POST | 3f04a4d4 |
PASSED — and the 404 mechanism is now NAMED, closing an r2 register item the gate was only expected to instrument. Detail below. |
| 5 | Live gate — FetchAndCache legs | (none — gate only) | PASSED, all six legs. Detail below. |
| 6 | Live gate — alarm-bearing surgical ratchet | (none — gate only) | PASSED, all four routings. Detail below. |
| 7 | Live gate — ACL-enabled enforcement | (none — gate only) | PASSED, with a topology adaptation and two non-blocking findings. Detail below. |
| 9 | Roslyn evaluator racing test — remove the timing assumption | 34829ea0, 837d8a17 |
Done. Cross-repo: ScadaBridge deferred-work register row 38, relayed 2026-08-18. Detail below. |
| 10 | LocalDb conditional registration + baseline + deregister self-heal | 3e62f161, df533916 |
Done. Cross-repo: ScadaBridge deferred-work register row 39. No pin bump. The scadaproj umbrella index was updated and pushed to main as 1145df6. Detail below. |
| 8 | Close-out — sweep + Outcomes + register annotations | this commit | Done. Rig torn down first, full build + sweep, this Outcomes section, and the append-only r2 register annotations. |
Task 4 — the first-POST 404, mechanism CONFIRMED
2026-08-18, local OrbStack docker-dev rig built from the branch tip. Two results, and the second is the one that matters.
- The prescribed fresh-boot run did NOT reproduce the 404 — the entire Traefik access log
for the boot window was 3 lines, all 2xx, every one carrying
RouterName = otopcua-admin@file. ⚠️ Anddeploy.shunderup -dcan never reproduce it: Compose blocks on thedepends_on: service_healthychain, soup -dreturned ~30 s after Traefik started and the first poll landed long after the window closed. - So the window was exercised directly — rapid-poll (~10 ms) across
docker restart otopcua-dev-traefik-1, twice. Result:000(port unbound) →404for ~65–160 ms →200. Body is exactly404 page not found(Go'shttp.NotFound), and the access-log signature is the discriminator the r2 round asked for:RouterName = "-",ServiceName = "-",0ms— no router matched, no backend dialled. - NAMED MECHANISM: the window between Traefik binding the
webentrypoint and loading the dynamic file-provider config from/etc/traefik/dynamic.yml. The listener accepts connections before the router table exists. - The competing candidate is excluded on evidence, not argument — a health-checked service with no healthy server yet would be a matched router answering 503; there are zero 503s anywhere in the session's access log.
- ⚠️ Caveat now recorded in both
deploy.shand the README: not all 404s are access-logged. Probe 1's seven 404s produced zero log lines; probe 2's produced 4 of 7. The accesslog handler is itself part of the dynamic chain that has not been built yet, so an empty access log does NOT prove the 404 did not happen. - No-body POST binding — VERIFIED, four request shapes (script stdin, no data flag at all,
explicit empty body, valid JSON), all reaching the handler. The minimal-API
DeployRequest?binds null; it does not 400 and does not 415. A clean post-seal run returned HTTP 202Accepted, and the DB confirms the null took the fallback:CreatedBy = rest-api.
Task 5 — FetchAndCache, all six legs
PASSED. Two independent full cycles (deploy 1e990163 rev 0b9289fb, then the revert
deploy da56012f rev 315de861), both sealed acks=6.
- Fetch + SHA-verify: all four site nodes —
Fetched + verified artifact for 1e99016312004ceca5f73aaba2c899b1 from http://central-1:4055 (1584 bytes).The template only logs after the SHA-256 == RevisionHash check, so its presence is the verify proof. The byte count tracked the composition (1584 → 1468 on revert). - Serve (central): four
Served artifact for … (1584 bytes, 1 chunks)oncentral-1. - Applied-from-fetch + cached: the
applied fetched deploymentvariant on every site node — and the twoDirectcentral nodes emitted the plainapplied deploymentform on the same deploy, which is the falsification control that the two templates are distinguishable. - Boot-from-cache: after
restart site-a-1,FetchAndCache boot — restored served state for deployment 1e9901… (rev 0b9289fb…) from the LocalDb pointer.— the wanted variant, not the empty-cache or failed-pointer ones, matching what the node cached 52 s earlier. - Absence proof over the node's whole 302-line lifetime:
entering Stale0, anystale0,ConfigDb back0, Direct-form apply 0, and[ERR]/[WRN]/[FTL]0. ⚠️ The Direct-form check must anchor on": applied deployment "— a naivegrep "applied deployment"matches the cache template and reports a false positive. - Bonus: a post-restart browse showed both namespaces live (
ns=2raw +ns=3UNS) including the temporary gate area — proof the node materialised the address space from the artifact it fetched and restored, not from a stale cache. - ⚠️ Method note worth carrying: same-revision deploys short-circuit the fetch path
entirely (
DriverHostActor.HandleDispatchFromSteadyimmediate-ACK). A bare re-run of this gate on unchanged config shows zero fetch lines and that is not a failure — force a new revision first. The gate did so with a temporaryUnsArearow, then reverted; the revert deploy came back byte-identical to the pre-gate revision.
Task 6 — the alarm-bearing surgical ratchet, four deploys
PASSED. All four applier lines emitted identically on central-1 and central-2, every
deploy sealed 6/6:
| Step | Lever | Applier line | Verdict |
|---|---|---|---|
| 1 | AccessLevel ReadWrite → Read |
kind=AttributeOnly, changed=1, surgicalRawTags=1, rebuild=False |
surgical ✅ |
| 2 | alarm ADD | kind=Rebuild, changed=1, surgicalRawTags=0, rebuild=True |
rebuild ✅ (Alarm != null current side) |
| 3 | alarm REMOVE | kind=Rebuild, changed=1, surgicalRawTags=0, rebuild=True |
rebuild ✅ (previous side also disqualifies) |
| 4 | AccessLevel Read → ReadWrite |
kind=AttributeOnly, changed=1, surgicalRawTags=1, rebuild=False |
surgical ✅ — the ratchet is per-deploy, not latched |
- Deviation, and why it is sound: the rig's ConfigDb holds exactly one
Tagrow, so TagS and TagA are the same tag exercised sequentially. Each step is an independent deploy producing exactly one raw-tag delta over orthogonal fields, so every verdict is unambiguous; a second tag would have added an added-node delta for no gain. - Proven at the artifact seam, not just the log: each
Deployment.ArtifactBlobwas pulled, gunzipped and semantically diffed — each step's artifact differs by exactly the intended lever and nothing else. - Only the cluster-MAIN nodes emit an applier line. Site nodes acked every deploy but
logged nothing:
DeploymentArtifact.ResolveClusterScopeself-scopes the fleet-wide artifact, the site plan is empty, andApplyearly-returns onplan.IsEmptybefore the summary log. Confirmed they did process each artifact via their authority-generation lines. - ⚠️ A live-gate fact worth knowing for every future rig gate: the revision hash can never
return to its baseline after any
UPDATE.ConfigComposersnapshots the SQL Serverrowversioncolumn into the artifact, androwversionincrements on everyUPDATEincluding one that writes back an identical value. A byte-diff confirmedRowVersionwas the only difference in the entire 4922-byte artifact. Semantic restore was verified instead (config hash back to7F75AB45…, rowcount unchanged).
Task 7 — ACL-enabled enforcement, first live run since the cache re-shape
PASSED, with a forced topology adaptation and two findings.
- ⚠️ The planned gated node was not executable.
site-aserves containers only — the rig's single tag belongs to cluster MAIN, sosite-a-1's authority generation always reportskeys=0, grants=0, resolvedScopes=0and there is no writable value node to authorize. The gate moved tocentral-1(fusedadmin,driver,cluster-MAIN, servingns=2;s=pymodbus/plc/HR200) via a scratchpad-only compose overlay — the repo compose file was never edited — withcentral-2as the ungated A/B control. Consequence recorded: central-1 is a ConfigDb node so key revocation is immediate rather than next-deployment; grants still arrive only via the artifact, so the code actually under test is exercised as intended. - Authoring fell back to direct ConfigDb INSERT (the Chrome extension was not connected).
ApiKeySecret.Generatewas reproduced from source and the pepper read live off the container; the construction is proven correct because the server accepted the key. - The ALLOW leg is genuinely load-bearing, because the ungated baseline was captured first.
The identical key and call on the ungated node returned
0x801F0000— theTag-scoped grant confers no role, soApiKeyInterimRoleMapperleaves the session withroles []and the server-wide gate denies. With the gate on, the same call returnedWrite successful. That is the live demonstration that the ACL decision REPLACES the role check rather than chaining with it — a deny-only gate would have passed against a build that never consults the ACL. - Denials fail closed: anonymous write and read both
0x801F0000withAclGate: Read withheld 1 of 1 node(s) for principal anonymous; out-of-scope browse refused; the ungated control node still open on the same NodeId. - Revocation was split into its two distinct code paths, and only the first tests this
round's fix. Grant removal → generation 1 → 2,
grants=0, resolvedScopes=0, a deliberately primed decision cache discarded, and the denial arrives whileApiKeyAuth: grantedstill logs — that is the per-key-shard/CAS-loop path. Key disable →0x80210000BadIdentityTokenRejectedandApiKeyAuth: denied — no enabled, unexpired key with id …— a different status, because the session never activates and never reaches the gate. - Restore verified by hash: the post-task revision returned to the exact pre-task
a3a9061bbf50…, with 0ApiKey/ 0NodeAclrows and the ENABLED warning gone.
Task 9 — the racing test, and an honest disclosure
Renamed to Evaluate_racing_ClearCompiledScripts_never_leaks_a_raw_disposed_failure and
re-shaped around three-bucket classification (leaked ⇒ fail on first; exhausted and starved
each bounded at max(evals/10, 8)) with duration-bounded loops (10 s or 400 iterations).
- ⚠️ The real load failure was not the one the ticket described. It was the test's own 45 s
WaitAsyncbudget expiring under recompile pressure, not the retry legitimately exhausting. Old shape: RED 4/4 at 4-way concurrency. New shape: GREEN 4/4, then 8/8. - The review found a further flake vector —
TimedScriptEvaluator's 5 s clock starts at enqueue, so a starved evaluation can time out without any product fault. Closed and falsified with a 12 ms-budget probe (old classification RED, new GREEN at a 0.11 % starve rate). - Failure-reason literals were promoted to a shared
ScriptEvalFailureReasonsin Commons, used by both evaluators and the test. - ⚠️ Disclosed honestly: the disposed race does not reproduce on this arm64 macOS box at all. The raw-escape assertion is the load-independent guarantee; the exhaustion/starvation bounds are calibrated loose (see the register).
Task 10 — LocalDb registration now follows replication config
Ported from ScadaBridge's WP1.3/WP3.3 pattern. Predicate = PeerAddress OR ApiKey
(SyncListenPort deliberately excluded — the passive endpoint fails closed on the key).
Configured ⇒ RegisterReplicated(table, baselineExistingRows: true) per table in the pinned
DDL→register order; not configured ⇒ DeregisterReplicated on every table at boot as an
idempotent self-heal. The CLAUDE.md bullets that recorded the unconditional shape as
deliberate were rewritten in the same commit.
- No pin bump. The 0.2.1 APIs were verified by reflection and a live probe (baseline
seeds
hlc=0;DeregisterReplicatedreturnsbool) rather than assumed from the changelog. - Two vacuous-fixture bugs were surfaced and fixed in passing (
LocalDbPairHarness, and anAlarmSfLegacyMigratortwo-config split) — fixtures that would have let the new predicate pass without being exercised. - Pair-symmetry is the operational trap, recorded in
CLAUDE.mdand the runbook: the sync handshake compares registered-table digests fail-closed, so flipping registration is a both-nodes-together change. - The scadaproj umbrella index was updated and pushed to
mainas1145df6, per the standing cross-repo rule.
Review chain
9 implementers; 12 review verdicts across the 4 reviewed code tasks (T1 spec + code + delta re-review; T2 spec + code + delta re-review; T9 spec + code + delta re-review; T10 spec + code + delta re-review); 4 fix rounds; 2 CHANGES REQUESTED (T2, T9). T3 was trivial (doc-only ⇒ no review) and T4–T7 are live gates with no code to review. Zero findings papered over; one coordinator ruling (T2's OCE flush).
Three evidence-hygiene lessons from this round, all worth carrying forward:
- ⚠️ A race harness can be green-on-defect, and you must measure its hit rate to know.
T1's plan-supplied
Barrierharness "passed" at ~1-in-19 000 — it looked like a working test. Only measuring the hit rate exposed thatBarrier's post-phase release hands one racer a head start. Round count is a function of the measured hit rate under full-suite parallelism, not a round number: 2 000 rounds was measured vacuous 1 time in 3 because misses burst-correlate. - ⚠️ Tear the rig down before a comparison sweep. A running rig silently un-skips env-gated tests; the r2 skip count is only comparable because the rig was down for both.
- ⚠️ Name the version of a count you are quoting. The "13 → 15" in this round's own close-out brief was a mid-round reading; against the merge-base it is 7 → 15. Intermediate counts drift the moment a review round lands.
Follow-up register (opened by this round)
Only genuinely new or still-open items. Everything this round closed is annotated on the round-2 register. None of these blocks the branch.
ACL
- ⚠️ Browse transit stops below the realm root — a grant-holding client cannot browse down to
its own granted tag. With the gate on, the key browses
ns=2;s=pymodbusandns=2;s=pymodbus/plcfine but is refused0x801F0000onns=2;s=OtOpcUaand on a recursive browse from the Objects root.PermissionTrieNode.BrowseTransitPrincipalsmarks the ancestors lying on the tag's RawPath; the synthetic realm root sits above the RawPath space and is not in the trie at all. It fails closed (not a security hole) and blocked no leg, but it would make an ACL-enabled deployment look broken to any browse-driven client — including the Client.CLI's own default recursive browse. Discovered by the T7 live gate. RECOMMEND filing a Gitea issue before anyone enablesSecurity:Acl:Enabledin production. Likely fix: mark the realm-root container browse-transit whenever any grant resolves beneath it.- Closed by
89630ee4+ff9d617a(branchfix/acl-anchor-browse-transit, 2026-08-18) — no Gitea issue needed; fixed directly. The actual mechanism was one step off the guess above: the trie's zero-segment realm-root branch already existed and was correct — nothing ever produced that scope, because the shared anchor's identifier (s=OtOpcUa, nowV3NodeIds.SharedRootFolderId) split into a one-segment scope no grant path contains.DataPlaneAuthorizer.BuildScopenow maps the anchor (realm-qualified: Raw namespace only) onto the zero-segment scope, andEvaluateadds a Browse-only second walk against the zero-segment Uns scope — the single anchor fronts both trees, so a UNS-only grant holder must also get through. 11 new tests (3 trie pins, 7 authorizer incl. falsification of the OR-leg and of the realm qualification, 1 e2e browse-succeeds-and-still- filters through a booted server). Full high-risk chain: spec ✅, code CHANGES REQUESTED (stale "two namespace root folders" XML remark) → fixed → delta re-APPROVED.
- Closed by
ns=0standard nodes are ungated — the boot warning overstates. With the gate on, both the key and anonymous readi=2256and browsei=2253successfully: those are served by the SDK'sCoreNodeManagerand never reachDataPlaneAuthorizer. Defensible (standard server metadata, no process data), but "A principal with no grant can connect and see nothing" is not literally true. Informational — a doc softening or a deliberate decision to gate ns=0.
S7
_healthremains a plain field, versus Modbus'sVolatile.Read/Volatile.Writeidiom. Pre-existing, but more of an outlier now that the read pass consolidates to a single end-of-pass write — the one place an alignment would be cheap and obvious. Follow-up alignment, not a correctness claim.- Worst-state-wins landed for the S7 READ pass only. Modbus (all paths) and the S7 WRITE path keep last-writer-wins. Deliberate scope — both were named out of scope in this plan because fixing them here doubles the blast radius of a watchdog-adjacent change. Recorded as a known asymmetry so the next reader does not mistake it for an oversight.
LocalDb
ZB.MOM.WW.LocalDb 0.3.1is published to the Gitea feed (per-table snapshot flags + a sync fault-teardown fix). This repo deliberately stays on0.2.1— the 0.2.1 APIs cover everything Task 10 needed, verified by probe. The per-database snapshot caveat is documented; 0.3.x makes it per-table. ⚠️ A bump is a both-nodes-together change (bookkeeping schema v2 → v3 is one-way), so it wants a planned window, not an incidental pin edit.
FetchAndCache
central-2's serve path was never exercised live. All four site nodes resolvedcentral-1:4055first and stopped there — the documented walk-in-order, stop-at-first-success behaviour, so central-2 served nothing. Not a fault; simply an untested half of the pair.
Scripting / test calibration
- The disposed race does not reproduce on this arm64 macOS box at all. The raw-escape assertion is the load-independent guarantee and holds regardless; the exhaustion and starvation bounds are calibrated loose on a machine that cannot produce the phenomenon they bound. They would benefit from a Linux-CI observation to tighten.
Test reliability
Client.CLI.TestsLoggerLifecycleTests.ConfigureLogging_DisposesPreviousLogger_BeforeReassigningis flaky under intra-assembly parallelism — observed once in the r3 close-out sweep, green in isolation (1/1) and on two whole-project re-runs (104/104). Mechanism: a pre-existing race on the process-globalSerilog.Log.Logger— the class carries no[Collection], so a concurrent test'sConfigureLogging(CloseAndFlush+ reassign) can swap the global between arrange and assert. No r3 commit touchessrc/Clientortests/Client. One-line fix when picked up: pin the class to a serial collection.
Carried from round 2, still open
- TM/CT decode at a non-zero device number still needs a real S7 CPU. The test exists and the
env var (
S7_TIMER_NUMBER_ADDRESSING=1) is ready; python-snap7 resolves TM/CT as a BIT address and cannot serve it. - The cross-process plant-vs-reap race on the shared SQL server is accepted. The 24 h age floor is the mitigation; a lock table on a shared dev server costs more than it buys.
Round-2 register items CLOSED by this round
- ACL single-shot CAS deferred publication → closed by
441b34a3(T1). - S7 per-item decode health last-writer-wins → closed by
4c161181+b35e423c+1765e300(T2), for the read path; the write-path and Modbus asymmetry is recorded above. - docker-dev first-POST 404 → mechanism NAMED by
3f04a4d4(T4), and the no-body POST binding VERIFIED by the same commit.