Files
lmxopcua/docs/plans/2026-08-17-perf-followups-r3.md
T

895 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 on `WriteAsync`; out of scope for the same reason, recorded.
- Historian end-to-end live gate — needs the `wonder-sql-vd03` VPN (user-driven).
- HistorianGateway batch `WriteLiveValues` RPC — 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:
```csharp
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).**
```csharp
[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.**
```csharp
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 the 98d6a190 defect.
- 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 `_generation` only rises.
- The `-1` seed 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.**
```bash
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 in `ReadAsync`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs`
(flip the `:423` pin; doc-comment `:389-393` rewritten; 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):
1. Flip `:423`: `One_undecodable_multi_var_item...` now expects `Degraded` (rename to say
worst-state-wins; rewrite doc-comment `:389-393` — the last-writer-wins pin is being
deliberately retired).
2. New: a mixed multi-var pass reports `Degraded` AND `LastSuccessfulRead` is advanced
(non-null / newer than the pre-pass value).
3. New: span path — one bad member decode then a good member ⇒ `Degraded` (today: Healthy).
4. New: an access-denied `PlcException` on one tag followed by a success ⇒ state stays
`Faulted` (ordering `Healthy < Degraded < Faulted`), `LastSuccessfulRead` advanced.
Use the per-tag path if the multi-var fake can't produce a per-item PlcException.
5. 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:
```csharp
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`) call `outcome.RecordSuccess()` instead
of assigning `_health`.
- `RecordReadFailure` takes the outcome, records `(Degraded|Faulted, message)` into it
instead of assigning `_health`, and KEEPS its `_plcDead` / `MarkConnectionDeadIfFatal`
side effects immediate. It still returns the per-item status code.
- One `_health` write at the end of the `ReadAsync` try body (NOT `finally` — the OCE
arms at `:649`/`:801`/`:920` rethrow and must skip it):
```csharp
_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 `:567` connect-failure early return; `HandlePollError` (`:1980`); the
entire write path; init/shutdown sites. `ReadSpanAsync` / `ReadLeftoversByMultiVarAsync`
gain 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 with
`OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker compose up`" is stale
- Modify: `CLAUDE.md` — same sentence in the `ConfigSource`/`ConfigServe` section
- 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:**
1. Fresh rig: `docker compose -f docker-dev/docker-compose.yml down -v` then
`up -d --build` (build mandatory — the branch tip must be in the images).
2. IMMEDIATELY run `./docker-dev/deploy.sh` (no body). Its 2 s `/health/ready` poll rides
through Traefik during the boot window — every poll is access-logged with
`defaultmode=keep`, and an unmatched request shows an EMPTY `RouterName`.
3. After the deploy completes (or fails), capture
`docker compose -f docker-dev/docker-compose.yml logs traefik` and extract every
non-2xx access-log line from the boot window. **If any 404 appears:** name the
mechanism from `RouterName`/`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). Update `docker-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.
4. No-body POST binding: with the rig ready, run `./docker-dev/deploy.sh` bare and record
the HTTP status + response body. Determine whether the minimal-API `DeployRequest?`
binds null (and what the endpoint then does) or 400s. Update the header NOTE in
`deploy.sh` from "unverified" to the observed behaviour, and `README.md` if wrong.
5. 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):
1. **Fetch + verify:** `docker compose -f docker-dev/docker-compose.yml logs site-a-1 |
grep -E "Fetched \+ verified artifact"` — expect
`Fetched + verified artifact for {DeploymentId} from http://central-… ({Bytes} bytes).`
2. **Served (central side):** `logs central-1 central-2 | grep "Served artifact"` —
`Served artifact for {DeploymentId} ({Bytes} bytes, {Chunks} chunks)`.
3. **Applied-from-fetch + cached:** `logs site-a-1 | grep -E "applied fetched deployment|cached applied deployment"` — both lines must appear (the `fetched` variant, NOT the
Direct `applied deployment` form).
4. **Boot-from-cache:** `docker compose restart site-a-1`, wait for boot, then
`logs site-a-1 | grep "FetchAndCache boot"` — expect
`restored served state for deployment {Id} (rev {Rev}) from the LocalDb pointer.`
5. **Absence proof:** on site-a-1 logs, assert NO occurrence of `entering Stale`,
`ConfigDb back`, or the Direct-form `applied deployment {Id} (rev` (without
`fetched`). A FetchAndCache node must never reach Stale.
6. 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:**
1. Pick TWO alarm-free raw tags in the rig's seeded config (browse `/raw` via AdminUI at
`http://localhost:9200`, login disabled — or read the ConfigDb `Tag` rows). Call them
TagS (surgical lever) and TagA (alarm lever).
2. **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`.
3. **Alarm add ⇒ rebuild:** add `"alarm": {"alarmType": "OffNormalAlarm", "severity": 500}`
to TagA's `TagConfig` JSON (raw-JSON textarea — available because the seeded device is
a Galaxy driver, which has no typed editor — or the same direct-DB route). Deploy.
Expect `kind=Rebuild, … surgicalRawTags=0, … rebuild=True`.
4. **Alarm remove ⇒ rebuild:** remove the `alarm` object, deploy, expect `rebuild=True`
again (Alarm != null on the PREVIOUS side also disqualifies).
5. **Restore:** revert TagS's access level, deploy once more (surgical again — also
re-proves the ratchet is per-deploy, not latched).
6. 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:**
1. 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`.
2. Mint a key: AdminUI `/apikeys` (Chrome automation; login disabled) — Name
`r3-acl-gate`, Principal `r3gate`. COPY the one-time `otua_…` credential immediately.
3. Author a grant: `/clusters/{site-a cluster id}/acls/new` — Principal `r3gate`, 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.
4. 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=…)`.
5. **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 (not `BadUserAccessDenied`);
any downstream Bad status is fine and should be reported as such.
6. **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`).
7. **Revocation reaches the artifact path:** disable the key on `/apikeys`, deploy,
confirm a NEW `data-plane authority generation {G+1} installed` on site-a-1, and the
same CLI write now fails auth (`ApiKeyAuth: denied — no enabled, unexpired key`).
8. **Teardown:** restore site-a ungated (`docker compose up -d site-a-1 site-a-2` without
the env), optionally delete the gate key/grant, and `docker compose down` the 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 to `Evaluate_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.md` LocalDb 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" = `PeerAddress` OR `ApiKey` present (OR, because only the
dialling half sets PeerAddress while the passive half carries just the key + listen
port — verify the exact key names against `LocalDbRegistration`).
- 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 ⇒ `DeregisterReplicated` on 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 both
`baselineExistingRows` and `DeregisterReplicated`. 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
1. Full build (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) — 0 errors; warning count vs r2's
904 explained.
2. Full unit sweep, every `*.Tests` project, per-project; reconcile the passed-count
delta against Tasks 1–2's added/changed tests exactly. DB suites must RUN (AdminUI
`0 skipped`, Configuration `0 skipped`).
3. Write `## Outcomes` on 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).
4. Annotate the r2 register in `2026-08-17-perf-followups-r2.md` append-only with
"Closed by <sha> (r3)" / "Gated live (r3)" sub-bullets.
5. Update the `.tasks.json` ledger; 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`. ⚠️ **And `deploy.sh` under `up -d` can never reproduce
it**: Compose blocks on the `depends_on: service_healthy` chain, so `up -d` returned ~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) → **`404` for
~65–160 ms** → `200`. Body is exactly `404 page not found` (Go's `http.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 `web` entrypoint 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.sh` and 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 202
`Accepted`**, 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)` on `central-1`.
- **Applied-from-fetch + cached:** the **`applied fetched deployment`** variant on every site
node — and the two `Direct` central nodes emitted the plain `applied deployment` form 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 Stale` **0**, any
`stale` **0**, `ConfigDb back` **0**, Direct-form apply **0**, and `[ERR]`/`[WRN]`/`[FTL]`
**0**. ⚠️ The Direct-form check must anchor on `": applied deployment "` — a naive
`grep "applied deployment"` matches the *cache* template and reports a false positive.
- **Bonus:** a post-restart browse showed both namespaces live (`ns=2` raw + `ns=3` UNS)
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.HandleDispatchFromSteady` immediate-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 temporary `UnsArea` row, 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** `Tag` row, 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.ArtifactBlob` was 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.ResolveClusterScope` self-scopes the fleet-wide artifact,
the site plan is empty, and `Apply` early-returns on `plan.IsEmpty` before 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`.** `ConfigComposer` snapshots the SQL Server
`rowversion` column into the artifact, and `rowversion` increments on every `UPDATE` including
one that writes back an identical value. A byte-diff confirmed `RowVersion` was the **only**
difference in the entire 4922-byte artifact. Semantic restore was verified instead (config
hash back to `7F75AB45…`, 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-a` serves **containers only** — the
rig's single tag belongs to cluster **MAIN**, so `site-a-1`'s authority generation always
reports `keys=0, grants=0, resolvedScopes=0` and there is no writable value node to authorize.
The gate moved to **`central-1`** (fused `admin,driver,cluster-MAIN`, serving
`ns=2;s=pymodbus/plc/HR200`) via a **scratchpad-only compose overlay** — the repo compose
file was never edited — with **`central-2` as 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.Generate` was 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` — the `Tag`-scoped
grant confers no role, so `ApiKeyInterimRoleMapper` leaves the session with `roles []` and the
server-wide gate denies. With the gate on, the same call returned **`Write 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 `0x801F0000` with
`AclGate: 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 while
`ApiKeyAuth: granted` still logs — that is the per-key-shard/CAS-loop path. *Key disable* →
`0x80210000` `BadIdentityTokenRejected` and
`ApiKeyAuth: 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 0 `ApiKey` / 0 `NodeAcl` rows 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
`WaitAsync` budget 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 `ScriptEvalFailureReasons` in 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`; `DeregisterReplicated` returns `bool`) rather than assumed from the changelog.
- **Two vacuous-fixture bugs were surfaced and fixed** in passing (`LocalDbPairHarness`, and an
`AlarmSfLegacyMigrator` two-config split) — fixtures that would have let the new predicate
pass without being exercised.
- **Pair-symmetry is the operational trap**, recorded in `CLAUDE.md` and 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 `main` as `1145df6`**, 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 `Barrier` harness "passed" at ~1-in-19 000 — it looked like a working test.
Only measuring the hit rate exposed that `Barrier`'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=pymodbus` and
`ns=2;s=pymodbus/plc` fine but is refused `0x801F0000` on `ns=2;s=OtOpcUa` and on a recursive
browse from the Objects root. `PermissionTrieNode.BrowseTransitPrincipals` marks 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 enables `Security:Acl:Enabled` in production.** Likely fix: mark the
realm-root container browse-transit whenever any grant resolves beneath it.
- **Closed by `89630ee4` + `ff9d617a` (branch `fix/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`, now `V3NodeIds.SharedRootFolderId`)
split into a one-segment scope no grant path contains. `DataPlaneAuthorizer.BuildScope` now maps
the anchor (realm-qualified: Raw namespace only) onto the zero-segment scope, and `Evaluate` adds
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.
- **`ns=0` standard nodes are ungated — the boot warning overstates.** With the gate on, both
the key and anonymous read `i=2256` and browse `i=2253` successfully: those are served by the
SDK's `CoreNodeManager` and never reach `DataPlaneAuthorizer`. 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**
- **`_health` remains a plain field, versus Modbus's `Volatile.Read`/`Volatile.Write` idiom.**
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.1` is published to the Gitea feed** (per-table snapshot flags + a sync
fault-teardown fix). **This repo deliberately stays on `0.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 resolved
`central-1:4055` first 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.Tests` `LoggerLifecycleTests.ConfigureLogging_DisposesPreviousLogger_BeforeReassigning`
is 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-global `Serilog.Log.Logger` — the class carries no `[Collection]`, so a concurrent test's
`ConfigureLogging` (`CloseAndFlush` + reassign) can swap the global between arrange and assert.
No r3 commit touches `src/Client` or `tests/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.