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

38 KiB
Raw Blame History

Perf Follow-ups Round 2 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. Opus implementers; classification-driven review chains.

Goal: Close the follow-up register opened by the 2026-08-17 perf follow-ups round (docs/plans/2026-08-17-perf-followups.md §"Follow-up register") — the ACL generation-rollover window, the S7 coverage gaps (cancellation branches, one-bad-decode, Timer/Counter live pin), the historian test hope-waits, the orphaned-test-DB reaper, the MTConnect ownership comment, and the docker-dev deploy readiness gate.

Architecture: No new subsystems. One production concurrency fix (generation stamp on the ACL cache's Shard), one production doc comment (MTConnect), one rig hardening (docker-dev/deploy.sh + Traefik access log); everything else is test/fixture work. Branch perf/followups-r2 off master (7bb888be).

Tech Stack: .NET 10, xUnit + Shouldly, Akka.NET TestKit, python-snap7 fixture on 10.100.0.35, SQL Server at 10.100.0.35,14330, Traefik v3.1 on the local OrbStack rig.

Out of scope (say so once, here): the cross-repo HistorianGateway batch WriteLiveValues RPC (sister repo); the VPN-gated historian E2E; the FetchAndCache / ACL-enabled / alarm-ratchet / load-scale live exercises (separate live-gate effort, on request).


SHARED-CHECKOUT RULES (every implementer, verbatim from round 1)

All implementers share ONE checkout on branch perf/followups-r2. Therefore:

  • Stage and commit with explicit pathspecs only: git add -- <files> then git commit -m "<msg>" -- <files>. NEVER bare git commit, git add ., git add -A.
  • NEVER run git checkout, git switch, git reset, git stash, git clean, or touch files outside your task's Files: contract.
  • On index.lock contention or transient build races (CS2012, MSB3030): wait 5–15 s and retry.
  • Falsification mutations (temporarily reverting a fix to prove a test bites) are made with Edit and reverted with Edit — never with git.
  • TDD red-first where the plan says so; when a test pins existing behaviour instead, label it honestly ("pin, not red-first") in your report.
  • Build/test only your own projects (dotnet build <csproj> / dotnet test <csproj> [--filter ...]), not the whole solution.

Task 1: Close the ACL generation-rollover window; capacity hint on eviction

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3, Task 4, Task 5, Task 6, Task 7

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs

The defect (pre-existing, recorded by round 1). InvalidateIfStale (lines 342–350) publishes the new _generation in the CAS at line 349 before the winner's ClearAll() (365–370) has purged the shards. A racer entering TryGet (167–183) in that window reads seen == generation, skips clearing, and serves an entry written under the previous generation from a shard the winner has not removed yet. Key revocation/expiry are unaffected (re-checked above the cache), so this is a stale grant walk only — but it is real, and the fix shape was already chosen in round 1's register: stamp the generation on Shard itself.

Spec:

  1. Shard (lines 378–391) gains a constructor taking long generation (stored in a public readonly long Generation field) and an optional initial-capacity hint for Entries (see step 6). All four construction sites in Set (lines 208, 221, 232, 236) pass the current query generation.
  2. TryGet serves a hit only when shard.Generation == generation. A generation-mismatched shard is a miss (do not evict from the read path — TryGet must stay allocation-free and non-mutating; the winner's ClearAll or the next Set reclaims it).
  3. Set: after acquiring the shard, if shard.Generation != generation, evict-swap exactly like the existing per-shard bound path (TryUpdate(sessionKey, new Shard(generation), shard)
    • Interlocked.Add(ref _globalCount, -Seal(shard)), then re-acquire) before inserting. _globalCount accounting must stay exact — the seal/booking protocol from d8849345 is load-bearing; do not weaken TryBookInsert/Seal.
  4. Deterministic pin of the window (this is the red-first test): add an internal test-only seam internal void AdvanceGenerationForTest(long generation) that CAS-moves _generation WITHOUT calling ClearAll — exactly the racer's view of the window. Test: Set under gen 1, AdvanceGenerationForTest(2), then TryGet(gen 2, …) for the same key must miss. Run it before the fix: it goes red (the stale entry is served). After the fix: green. Falsification after fixing: temporarily remove the shard.Generation check via Edit, confirm red, restore.
  5. Extend the existing stress test (lines 186–264) or add a sibling: interleave generation bumps with readers/writers and assert no hit ever returns a value stored under an older generation (encode the generation in the stored bool — e.g. store gen % 2 == 0 — and assert every hit equals the current generation's encoding). Also keep asserting TrackedCount/LiveShardCount exactness at quiescence.
  6. Capacity hint (second register item, same ctor): only the eviction-swap site (line 232 path) passes a capacity hint — the evicted shard's sealed count, clamped to a sane bound (e.g. Math.Clamp(captured, 0, MaxEntriesPerShard) or the existing per-key ceiling const) — because a shard evicted at the ceiling is the crawler signal. Plain GetOrAdd shards stay default-capacity. One test: after an eviction swap, inserts proceed correctly (a behaviour pin; capacity itself is not observable — say so in the test comment).
  7. Update the class-level comments where they describe the invalidation story; do not restate history, state the invariant ("a shard can only serve the generation it was built under").

Steps: red test (4) → fix (1–3) → green → falsification → stress (5) → capacity (6) → dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~SessionAclDecisionCache" → commit with pathspecs: git commit -m "fix(acl): stamp the generation on Shard so a rollover racer cannot serve a stale-generation verdict; eviction-swap capacity hint" -- <the two files>.


Task 2: S7 read-path coverage — cancellation branches + one bad decode in a chunk

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3, Task 4, Task 5, Task 6, Task 7

Files:

  • Test (only): tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs

Context. Three read paths carry the STAB-15a cancellation contract (_plcDead = true + rethrow): per-tag (S7Driver.cs:649-660, covered by S7DriverReconnectTests.Cancelled_read_marks_handle_dead_and_next_read_reopens), span block read (S7Driver.cs:920-926, uncovered), multi-var leftovers (S7Driver.cs:803-809, uncovered). The per-chunk fallback (:810-817) has one whole-request-fault test; the per-ITEM decode guard (:819-836, null value → InvalidDataException → that tag Bad) and the shape-mismatch arm (:796-801) have none. ImageS7PlcFactory (S7SpanReadParityTests.cs:453, ImageS7Plc at :480) exposes only ReadFault and MultiVarFault knobs today.

Spec — all tests are pins of existing behaviour unless marked red-first:

  1. Extend ImageS7PlcFactory/ImageS7Plc with three knobs, mirroring the existing fault knobs' style:
    • HangReadBytes (a TaskCompletionSource gate): ReadBytesAsync awaits Task.Delay(Timeout.Infinite, ct) so cancellation is the only exit — the S7DriverReconnectTests fake at :557-560 is the pattern.
    • HangMultiVar: same for ReadMultipleVarsAsync.
    • NullItemIndex (int?): ReadMultipleVarsAsync returns the normal answered list but with item N's Value left null — the decode guard's trigger at :824-826. Track a CreatedPlcCount on the factory if not already present (reopen observable).
  2. Cancelled span block read: plan spans on, hang ReadBytesAsync, cancel the read's CTS, assert OperationCanceledException propagates out of ReadAsync, and the NEXT read constructs a fresh plc (CreatedPlcCount increments — the _plcDead reopen contract), using the same assertion shape as the reconnect test at S7DriverReconnectTests.cs:338-355.
  3. Cancelled multi-var read: spans on with a leftover batch (reuse the existing multi-var-eligible tag set from :257/:281), hang ReadMultipleVarsAsync, cancel, same two assertions.
  4. One bad decode inside an otherwise-good chunk: NullItemIndex = 1 on a 3-item multi-var chunk → exactly that tag lands Bad (RecordReadFailure status), its chunk-mates Good with correct values, MultiVarCalls == 1, and no per-tag fallback reads occur for the good items (per-item failure is not chunk failure). Assert driver health reflects the partial failure the same way the existing fallback test does.
  5. Shape mismatch: a knob (or reuse NullItemIndex-style) making ReadMultipleVarsAsync return chunk.Count - 1 items → the whole chunk falls back per-tag (:796-801 → InvalidDataException → :810-817), all values still served Good via fallback.
  6. Falsification for (2) and (3): via Edit, temporarily change the production OCE arm to swallow (mimic the pre-#527-style bug) and confirm the reopen assertion goes red; restore. Note in the report which line was mutated.

Steps: knobs → tests (each run individually) → falsification → full file run (dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7SpanReadParityTests") → commit test(s7): pin the span/multi-var cancellation reopen contract, one-bad-decode-in-chunk, and multi-var shape mismatch -- tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs.


Task 3: Timer/Counter live pin — TM/CT area in the s7_1500 fixture

Classification: standard (live-gated) Estimated implement time: ~5 min authoring + fixture redeploy wall-time Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7 (disjoint files; the live leg runs against the shared fixture host, not the checkout)

Files:

  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs
  • Modify (if needed for seeding TM/CT): tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md

Context. The round-1 gate could not live-pin the Timer/Counter multi-var exclusion because the fixture profile declares only DB1 + MK (s7_1500.json), though server.py:34-41's AREA_MAP already supports TM/CT (snap7.type.SrvArea). The exclusion itself: S7ReadSpanPlanner.cs:94-103 (planner → leftovers) and S7Driver.cs:1129-1132 (IsBufferType → never multi-var). Timer/Counter entries are 2-byte device-number-indexed cells; a "device number N" seed lands at byte offset 2*N big-endian in the area buffer.

Spec:

  1. Add TM index 0 and CT index 0 areas (size 64 bytes each = 32 devices) to s7_1500.json with 2–3 distinct non-zero seeds each (the #536 rule: a zero-valued source proves nothing) at known device numbers, _desc'd. Keep every existing seed byte-identical — the round-1 tests depend on them.
  2. New [SkippableFact]-style test (follow the file's existing gating convention) in S7_1500SpanReadTests.cs: author a tag set mixing multi-var-eligible scalars with one Timer and one Counter tag; spans on. Assert:
    • the Timer/Counter values read back as the seeded non-zero constants (correct decode on a real wire), and
    • via CountingPlc, no multi-var request ever contains a Timer/Counter item and the TM/CT reads arrive as their own block/per-tag requests — the live pin of the exclusion.
  3. Accepted alternate outcome, stated in advance: S7.Net's TM/CT read is a distinct wire shape the python-snap7 server has never served. If it genuinely cannot (live evidence: error/exception captured, not a guess), do NOT force it: env-gate the test with a skip reason naming the fixture limitation, record the evidence in the Outcomes, and the exclusion stays unit-pinned. A red herring here must not burn the task.
  4. Fixture redeploy recipe (round-1 ops notes, both bit last time): rsync -av --rsync-path="sudo rsync" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-s7/ then ssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose build && docker compose up -d --force-recreate' — --build is mandatory (profile is baked into the image) and confirm seeded DB1 … seeds=<n> (and the new TM/CT lines) in docker logs.
  5. Run: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~S7_1500SpanReadTests" with the fixture live. All pre-existing tests must stay green (the seed-compat rule in step 1).

Commit: test(s7): live-pin the Timer/Counter multi-var exclusion against a TM/CT-seeded s7_1500 fixture with pathspecs.


Task 4: Historian recorder tests — bounded watches instead of hope-waits

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 3, Task 5, Task 6, Task 7

Files:

  • Test (only): tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs

Context. Three fixed sleeps synchronize negative assertions ("X did not happen"): line 602 (FromMilliseconds(500) before asserting no truncation after a shutdown-cancelled write), line 452 (300 ms before asserting an abandoned tag stopped being rewritten), line 857 (300 ms before asserting sequential-drain concurrency). A negative can't be polled for directly; each needs a positive completion observable, then the assert. The Task-9 treatment (FasterLogHistorizationOutboxTests.cs:203-231) is the pattern for the poll shape (Stopwatch + 25 ms + cap, return last observation). InMemoryOutbox has AppendCallCount/AppendBatchCallCount/PeekBatchCallCount/DroppedCount but no RemoveCallCount.

Spec:

  1. Add RemoveCallCount to InMemoryOutbox (increment in RemoveAsync), same style as the existing counters.
  2. Line 602 test: replace the sleep with a bounded watch: after writer.Cancelled, poll (25 ms / 2 s cap) for the bug's positive signal — RemoveCallCount > 0 or CountAsync != 2 — failing fast red if either fires, green after the full window if neither does. Comment: "bounded negative watch — red is immediate when the fold truncates, green costs the window once". Keep the existing preconditions (594–595) untouched.
  3. Line 452 test: replace wall-clock with the drain's own clock: snapshot PeekBatchCallCount, poll until it has advanced by ≥ 3 (more passes have demonstrably run), then assert the Good.Tag write count is unchanged. Cap 5 s; on cap-out without 3 passes, fail with a message naming the observed pass count.
  4. Line 857 test: same pass-count clock (≥ 2 further passes) before the concurrency asserts; keep the existing AwaitAssertAsync precondition.
  5. Do NOT touch the deliberate mechanics delays (1476, 1640, 1656, 1673 — documented pool-thread forcing; leave them and their comments alone).
  6. Each reworked test: run 5× locally (--filter) to check stability; note the timings in the report.

Commit: test(historian): replace the recorder tests' fixed sleeps with bounded watches on drain-pass and truncation observables with pathspecs.


Task 5: Orphaned test-database reaper (AdminUI + Configuration fixtures)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 3, Task 4, Task 6, Task 7

Files:

  • Modify: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs
  • Modify: tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs
  • Test: a new [SqlServerFact] (AdminUI side) proving the reaper drops an old-named DB

Context. A killed test process leaks its uniquely-named database forever: OtOpcUaAdminUi_Test_<yyyyMMddHHmmss>_<GuidN> (AdminUiSqlServerFixture.cs:77) and OtOpcUaConfig_Test_… (SchemaComplianceFixture.cs:30). The fixture comment at 127–128 even says the timestamp exists "so a leaked one is identifiable" — the reaper was never written. Natural insertion point: fixture ctor, after the probe early-return (:75), before the name assignment (:77). The drop SQL to reuse is :118-121; TryDropDatabase must be extracted to a static taking a name.

Spec — safety first, this runs DROP DATABASE against a shared server:

  1. Extract the drop body into a static TryDropDatabase(string databaseName) (best-effort, DbException-swallowing, exactly the current semantics); the instance method delegates.
  2. Add a static reaper, run once per process behind a Lazy in AdminUiSqlServer (mirroring the probe): SELECT name FROM sys.databases WHERE name LIKE 'OtOpcUaAdminUi\_Test\_%' ESCAPE '\', parse the embedded yyyyMMddHHmmss (UTC), and drop ONLY rows older than 24 hours; a name whose timestamp does not parse is left alone. Never throw — a reaper failure must not fail a green run. Log nothing louder than Console.WriteLine (test infra convention in this file).
  3. Same shape in SchemaComplianceFixture for the OtOpcUaConfig_Test_ prefix, one-shot per process. (It has no probe/skip machinery — keep the reaper catch-all so an unreachable server behaves exactly as today.)
  4. Test (red-first is impossible for a time-gated reaper without waiting a day, so pin it deterministically): a [SqlServerFact] that creates an empty DB named with a >24 h-old timestamp (e.g. OtOpcUaAdminUi_Test_20250101000000_<guid>), invokes the reaper's core (make the core internal-testable, taking the cutoff as a parameter), and asserts the DB is gone — and a second DB named with a fresh timestamp survives the same call. Clean up the survivor in the test.
  5. The mid-run-blip semantics (one-shot probe → mid-run SqlException = failures, not skips) stay as-is — they are already documented at :34-38; verify the comment survives your edit, extend it with one line noting the deliberate non-choice if it doesn't already say so.

Commit: test(sql): reap >24h-old orphaned test databases at fixture startup (AdminUI + Configuration prefixes) with pathspecs.


Task 6: MTConnect AsStream buffer-ownership comment

Classification: trivial Estimated implement time: ~1 min Parallelizable with: all

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs (the AsStream doc comment at lines 241–249)

Spec: extend the existing <summary> with the ownership facts (verified this round): the array-backed branch aliases the caller's array without copying; the buffer is not pooled or rented anywhere on this path; parsing is fully synchronous, so the buffer must stay valid and unmutated only for the duration of the call; the returned MTConnectStreamsResult holds no reference into the buffer (XmlReader materialises its own strings). Comment-only — no code change, no test.

Commit: docs(mtconnect): state AsStream's buffer aliasing and lifetime contract with pathspec.


Task 7: docker-dev readiness-gated deploy helper + Traefik access log

Classification: small Estimated implement time: ~4 min Parallelizable with: all

Files:

  • Create: docker-dev/deploy.sh
  • Modify: docker-dev/docker-compose.yml (Traefik command block, lines ~912-916)
  • Modify: docker-dev/README.md (the bare POST snippet at lines ~88-91)

Context (root-cause facts, record them in the README): the transient first-POST 404 can NOT come from the AdminUI (all routes map synchronously before RunAsync (Program.cs:740 vs :803); the endpoint's own failures are 503/401/422, never 404) and can NOT be Traefik's backend-down answer (that is 503 no available server). A 404 from :9200 means no Traefik router matched at that instant — mechanism unconfirmed; the access log below is how the next occurrence gets diagnosed.

Spec:

  1. deploy.sh (bash, set -euo pipefail): polls http://localhost:9200/health/ready until HTTP 200 (2 s interval, 120 s cap — a 200 through Traefik proves both the router table and a ready backend), then POSTs the artifact exactly as the README snippet does (X-Api-Key: docker-dev-deploy-key, JSON body from $1 or stdin), prints the response, and exits non-zero on any non-2xx. No retry on the POST itself — the gate is the fix, and a post-gate 404 should fail loudly (it would be new evidence).
  2. Add --accesslog=true (and --accesslog.fields.defaultmode=keep) to the Traefik command list so a recurrence shows which router (or none) matched.
  3. README: replace the bare POST snippet with ./deploy.sh, and add a short "first-POST 404" note carrying the root-cause facts above.
  4. Verification is docker-dev-rig-dependent; bash -n deploy.sh + a docker compose config parse check are the in-checkout gate. The rig run happens in Task 8's close-out if the rig is brought up; do not block this task on it.

Commit: chore(docker-dev): readiness-gated deploy.sh + Traefik access log for the first-POST 404 with pathspecs.


Task 8: Close-out — sweep + Outcomes + register annotations

Classification: standard Estimated implement time: ~10 min (mostly sweep wall-time) Parallelizable with: none (blockedBy all)

Files:

  • Modify: docs/plans/2026-08-17-perf-followups-r2.md (append ## Outcomes)
  • Modify: docs/plans/2026-08-17-perf-followups.md (append-only "closed by " annotations on the round-1 register items this round closes; the two live-exercise items that stay open stay open)
  • Modify: docs/plans/2026-08-17-perf-followups-r2.md.tasks.json (ledger)

Steps:

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors (pre-existing warnings tolerated).
  2. Full per-project unit sweep of every *.Tests project (--no-build after the solution build), same protocol as round 1. DB-backed suites must RUN (not skip) against 10.100.0.35,14330. Expected: 0 failed; new-test count above round 1's 10,358 baseline.
  3. S7 integration suite against the live fixture (Task 3's evidence re-confirmed at the final tree).
  4. Append ## Outcomes: per-task table (commit, outcome, deviation), the TM/CT live evidence (or the documented fixture limitation), the reaper's first live reap count, any new follow-up register (only genuinely new items).
  5. Annotate the round-1 register (append-only, never rewrite): ACL rollover + capacity hint, S7 cancellation + one-bad-decode + TM/CT, historian hope-wait, AdminUI reaper (blip semantics = documented wontfix), MTConnect comment, docker-dev readiness gate — each with its closing SHA.
  6. Commit docs with pathspecs; update the ledger.

Execution notes for the controller

  • Wave 1: Tasks 1–7 dispatched concurrently (all Files: contracts disjoint). Task 8 after all verdicts land.
  • Review chains per classification: T1 high-risk (spec serial → code); T2/T3/T4/T5/T8 standard (spec ∥ code); T7 small (code only); T6 trivial (none).
  • Implementers: Opus (user preference), including the trivial task for uniformity.
  • Reviewers judge via git show <sha>; file-disjoint scheduling makes on-disk fallback acceptable as in round 1.

Outcomes

Written at close-out, 2026-08-17, on branch perf/followups-r2. All 8 tasks complete. This round is a follow-up round: every item it closes was opened by the round-1 register in 2026-08-17-perf-followups.md, and that register is annotated in place with the closing SHAs.

Sweep record

dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors, 904 warnings — the same two pre-existing families round 1 recorded (xUnit1051 cancellation-token advice and the repo's own OTOPCUA0001 CapabilityInvoker analyzer firing on test call sites), up 8 from round 1's 896 because this round's new test call sites are more of the same. No new warning kind.

Full unit sweep — every *.Tests project in the solution, run per-project with --no-build against the tree at 580b77ce. *.IntegrationTests are build-only by convention; the S7 suite ran separately as Task 3's live gate (below).

43 / 43 test projects green (exit code 0 on every one).

Round 1 This round Δ
Passed 10 358 10 370 +12
Failed 0 0 —
Skipped 19 19 —
Total 10 377 10 389 +12

No test failed, so no isolation re-run was needed, and nothing flaked. The +12 reconciles exactly against the three suites this round added tests to, with nothing unaccounted for:

Project Round 1 This round Δ Source
OpcUaServer.Tests 476 481 +5 SessionAclDecisionCacheTests 12 → 17 (T1)
Driver.S7.Tests 397 401 +4 S7SpanReadParityTests 9 → 13 (T2)
AdminUI.Tests 1 537 1 540 +3 SqlTestDatabaseReaperTests (T5)

⚠️ Note the S7 delta is +4, not the +5 the plan estimated — the plan's task text counted five contracts pinned, which landed as four test methods (one method carries two of them). T4 added no tests (it reworked 3 of the 5 existing recorder tests in place), and the S7 integration suite's +2 do not appear here because it is build-only in the sweep.

All 19 skips are the pre-existing, env-gated ones round 1 recorded, 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.

The DB-backed suites ran against a reachable 10.100.0.35,14330 — executed, not skipped: AdminUI.Tests 1540 passed / 0 skipped and Configuration.Tests 190 / 0 skipped. That zero is the load-bearing number for T5: a reaper suite that skipped itself would have proved nothing about the DDL it runs as sa.

Two verifications outside the sweep proper, because two comment-only fixes (below) landed after the sweep's build: the solution rebuilt 0 errors, and OpcUaServer.Tests re-ran 481 / 0 / 1, unchanged.

Per-task outcomes

# Task Commit(s) Outcome + deviation
1 ACL generation stamp on Shard + eviction capacity hint 1979cf41, 98d6a190 Done, and the review found a second, pre-existing defect that was closed in-round. The generation is now stamped on Shard (ctor + readonly Generation); TryGet is generation-gated and non-mutating; Set displaces a mismatched shard and drops the verdict if it is still mismatched after the displacement. The eviction capacity hint is gated on the generation matching — a previous generation's fill level is not evidence about this one. 98d6a190 added a monotonic <= guard to InvalidateIfStale after the reviewer spotted that a stale-generation straggler could roll the stamp backward and thrash ClearAll. Falsifications: removing the stamp check went deterministically red on the pin and produced 1 357 cross-generation hits in the stress test; reverting the <= guard to == rolled the stamp 2 → 1. 17/17.
2 S7 cancellation branches + one-bad-decode + shape mismatch 4f860733, 120e885b Done. The span and multi-var cancellation reopen contracts are pinned (hang knobs + entered-TCS + CreatedPlcCount), plus one-bad-decode-in-chunk (only that tag goes Bad, no fallback) and the short-answer shape mismatch (whole-chunk fallback). 120e885b strengthened the pins to assert the stale handle is disposed, not merely replaced. Falsification: each OperationCanceledException arm was weakened in turn and exactly the matching test went red — no arm was covered by a neighbour's test. 13/13.
3 Timer/Counter live pin — TM/CT area in the s7_1500 fixture fd202d81, f797df51 LIVE PIN PASSED vs 10.100.0.35:1102. Evidence + the fixture quirk below.
4 Historian recorder tests — bounded watches 45d67092, 68461fbf Done; three fixed sleeps → bounded watches. Detail below.
5 Orphaned test-database reaper 8946644b, 580b77ce Done — and the code review found a real Critical, fixed pre-merge. Detail below.
6 MTConnect AsStream ownership comment 1edfc263 Done as specified; comment-only, no test. Trivial ⇒ no review.
7 docker-dev readiness-gated deploy.sh + Traefik access log a61b3c98, 9f09a62a Done as a gate plus instrumentation, not a diagnosis — say so plainly. Detail below.
8 Close-out — sweep + Outcomes + register annotations d54b4fab, this commit Done. d54b4fab carries the two comment-only nits the T1 re-review left for close-out; this commit carries the Outcomes, the sweep record, the round-1 register annotations, and the ledger.

Live pin — S7 Timer/Counter multi-var exclusion (Task 3)

2026-08-17, against the s7_1500 fixture at 10.100.0.35:1102. PASSED — 4 passed / 1 env-gated skip.

TM and CT areas were added to the fixture profile: TM0 (T0 = 0x2456 = 456.0 s, T5 = 0x1123 = 12.3 s, T9) and CT0 (C0 = 4711, C7 = 31337, C11).

  • The claim under test, asserted on real wire shapes: a batch mixing Timer/Counter tags with ordinary ones produced 1 block read + 1 multi-var request carrying 3 scalar items + one 2-byte TM read + one 2-byte CT read, and MultiVarItemAreas never contains Timer or Counter. The round-1 exclusion — added because S7.Net boxes TM/CT as int where the single-read path yields ushort, and box parity is load-bearing downstream — now rests on the wire, not on unit tests alone.
  • ⚠️ Fixture quirk, discovered here and documented with a recaptured live probe transcript: python-snap7's server resolves a TM/CT request address as a BIT address (it serves byte addr >> 3) where S7.Net and a real CPU send the device number. Device 0 is the fixed point of both conventions, which is why value decode is live-pinned at T0/C0.
  • The device-number leg (T5/C7) is therefore env-gated on S7_TIMER_NUMBER_ADDRESSING=1 — it is the single skip above, and it is a ready-made real-CPU gate rather than a silent omission. Not closed by this round: TM/CT decode at a non-zero device number still needs a real CPU.

Task 4 — what replaced each sleep, and the one place a pass clock does not exist

Three fixed sleeps became bounded watches on real observables, not shorter sleeps:

  • Shutdown test: a 25 ms/1 s negative watch on the new RemoveCallCount and the queue depth. The 1 s cap is not a guess — it is anchored to the measured ~150 ms at which the falsification tripped, so the cap is roughly 6× the observed signal rather than an arbitrary round number. 68461fbf also reduced it to a single counter read per poll (two reads per poll can straddle an increment and compare inconsistent values).
  • Poison-tag test: a positive watch on PeekBatchCallCount ≥ 3 — a genuine pass clock.
  • ⚠️ Sequential test: a 25 ms/500 ms negative watch, and this is a disclosed deviation. A pass clock is structurally unavailable there: the drain is single-flight on _draining and the timer re-arms only in OnDrainResult, so there is no counter that advances while the condition under test is false. Verified against the production code and accepted rather than worked around with a fake observable.

Falsification: tripped on RemoveCallCount in ~150 ms. 5× stability green.

Task 5 — the reaper's Critical, and why the live evidence is shaped the way it is

The startup reaper drops test databases orphaned > 24 h (AdminUI + Configuration prefixes, per fixture, one-shot Lazy, probe-gated on the AdminUI side).

  • ⚠️ The code review found a real Critical. The name-shape guard validated only the prefix and the timestamp, so any ..._<14 digits>_<anything> database name on the server would flow into string-interpolated ALTER/DROP DDL executed as sa. Fixed defense in depth: a Guid.TryParseExact("N") suffix validation and parameterized QUOTENAME/sp_executesql. ⚠️ The reviewer's proposed EXEC('…' + QUOTENAME(@name)) snippet was itself invalid T-SQL (verified live — Msg 102); the accepted fix is not the one that was suggested.
  • Also landed: a 3 s reaper ConnectTimeout (a reaper must never be the thing that hangs a test run), DatabaseName minted from the prefix constant rather than re-spelled, the catch widened to Exception, and the reaper tests joined to the AdminUI SQL collection — closing a same-process parallel-collection race that earlier 2-test runs had masked.
  • Live evidence: planted orphans were reaped by exactly the right fixture; non-GUID and unparseable names were spared; the shared server was restored afterwards. 3/3 reaper, 1540/1540 AdminUI ×3, 190/190 Configuration.

Task 7 — what the docker-dev change does and does not establish

deploy.sh polls /health/ready (2 s interval, 120 s cap) and then POSTs once, un-retried (both curls guarded under set -e, --max-time 10/60), and Traefik now runs --accesslog.

⚠️ The 404's mechanism is still unconfirmed. What is established is the elimination: the AdminUI cannot produce it (routes map synchronously before RunAsync; the endpoint's own failure modes are 503/401/422), and Traefik's backend-down answer is 503 no available server, not 404. So a 404 means no router matched at that instant — and the access log is how the next occurrence gets named. The gate makes scripted deploys reliable; it does not explain the bug.

Review chain

7 implementers; 11 review verdicts (T1 spec + code + delta re-review, T2 spec + code, T3 spec + code, T4 spec + code, T5 spec + code + re-review, T7 code + re-review); 4 fix rounds (T1, T3, T5, T7, plus minors on T2/T4); 1 Critical found and fixed pre-merge (T5's DDL injection); zero findings papered over.

Two falsification-hygiene lessons from this round, both worth carrying forward:

  • A falsification must be run against a freshly built binary. A when (false) mutation combined with a --no-build run executed the stale binary and reported a vacuous green — the mutation "passed" without ever having been compiled.
  • A green falsification arm proves nothing until you have seen its red. T1's Set-displacement behaviour was unpinned until a test written specifically for it went red; the existing tests all passed with the displacement removed.

Follow-up register (opened by this round)

Only genuinely new or still-open items. Everything else this round touched is annotated as closed on the round-1 register. None blocks the branch.

ACL

  • The single-shot CAS can defer stamp publication by one call. When generation N wins the race for a shared baseline against generation N+1, publication of the newer stamp is deferred one call. Pre-existing and benign — it self-heals on the next call and no verdict is affected (a deferred stamp cannot serve a stale grant; the generation gate on TryGet is what decides that, and it is unaffected). Recorded so the next reader does not re-derive it as a bug.
    • Closed by 441b34a3 (r3). InvalidateIfStale is now a monotonic-max CAS loop with the <= guard re-read inside each attempt, so a straggler that loses the CAS retries against the moved stamp instead of falling out unpublished.

S7

  • Per-item decode health is last-writer-wins. Pre-existing; now pinned and documented rather than fixed, because changing it is a behaviour change to how a mixed-outcome chunk reports driver health, not a test gap.
    • Closed by 4c161181 + b35e423c + 1765e300 (r3), for the READ path. One worst-state-wins accumulator per ReadAsync pass, one end-of-pass _health write, with LastSuccessfulRead advanced whenever any item succeeded. ⚠️ Modbus (all paths) and the S7 WRITE path deliberately keep last-writer-wins — recorded as a known asymmetry on the r3 register, not an oversight.
  • TM/CT decode at a non-zero device number still needs a real CPU. The test exists and the env var (S7_TIMER_NUMBER_ADDRESSING=1) is ready; python-snap7 cannot serve it (see the BIT-address quirk above).
    • Carried (r3). Unchanged — still blocked on hardware; explicitly out of scope in the r3 plan.

docker-dev

  • The first-POST 404's mechanism is unconfirmed until the new access log catches an occurrence. The gate prevents the symptom; the cause is still open.
    • Closed by 3f04a4d4 (r3). Mechanism: the window between Traefik binding the web entrypoint and loading the dynamic file-provider config — the listener accepts before the router table exists (~65–160 ms on the rig; empty RouterName/ServiceName, 0ms, Go's 404 page not found). The 503 candidate is excluded on evidence. ⚠️ Not all 404s are access-logged — the accesslog handler is itself part of the not-yet-built dynamic chain, so an empty log does not prove absence.
  • The no-body POST Content-Type binding is unverified against a live rig — carried as a header NOTE in deploy.sh rather than silently assumed correct.
    • Closed by 3f04a4d4 (r3). Verified live across four request shapes: DeployRequest? binds null (no 400, no 415), the request returns 202 Accepted, and the handler's fallback puts CreatedBy = rest-api — confirmed in the ConfigDb.

SQL test infrastructure

  • A cross-process plant-vs-reap race is inherent to a shared, unlocked server and is accepted: two test processes racing on the same instance can have one reap what the other just planted. The 24 h age floor makes it vanishingly unlikely in practice, and the alternative (a lock table on a shared dev server) costs more than it buys.
    • Carried (r3). Still accepted, unchanged; explicitly out of scope in the r3 plan.