# Perf Follow-ups 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 perf-roadmap-remainder follow-up register (`docs/plans/2026-08-15-perf-roadmap-remainder.md` §"Follow-up register") and run the two release-condition live gates, on branch `perf/followups` off master merge `e40a0385`. **Architecture:** No new subsystems. Each task is a surgical close of a recorded follow-up: a metrics ride-along on the existing shared `OtOpcUaTelemetry.Meter`, four defect fixes in already-reviewed code (drain cancellation, ACL eviction race, revalidate memo key, `TrackBackgroundTask` ordering), two SQL-Server-backed test gaps, one parser streaming rewrite, an S7 multi-var read extension over the existing `IS7Plc` seam, and two live gates (s7_1500 fixture + local docker-dev rig). **Tech Stack:** .NET 10 / C#, xUnit + Shouldly, System.Diagnostics.Metrics, EF Core (SqlServer + InMemory), S7netplus 0.20.0, FasterLog, docker-dev rig (OrbStack). **Out of scope (recorded, deliberately not here):** - **HistorianGateway multi-tag batch `WriteLiveValues` RPC** — cross-repo (`~/Desktop/HistorianGateway` proto + client-package release + pin bump here). Stays in `docs/Historian.md` as the recorded ceiling on `DrainConcurrency`. - **S7 `SByte`** — a plan error, already corrected; not a gap. - `UpsertNodeDeploymentState` / `TryRecoverFromStale` synchronous writes — commented in `DriverHostActor.cs:2930-2932` / `:2979-2982` as deliberate; not on the hot path. - Host-side mux interest gating — recorded as not-worth-it after the batch envelope. - Historian end-to-end live gate against the real AVEVA Historian — needs the `wonder-sql-vd03` VPN (user-driven); recipe stays `docs/plans/2026-08-01-continuous-historization-live-gate.md`. - Pushing master — user decision, unchanged. **Execution rules (unchanged from the remainder plan):** shared checkout — explicit-pathspec `git add`/`git commit -- ` 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`, never the working tree; falsification mutations reverted via Edit; TDD red-first with honest "pin, not red-first" reporting where the behaviour predates the test. --- ### Task 1: Per-driver poll-overrun meter + PollGroupEngine doc notes **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 2, 3, 4, 5, 6, 7, 8, 10, 11 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs` (new counter) - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs` (doc notes only) - Modify (one-line each, at the `new PollGroupEngine(` call sites): `ModbusDriver.cs:157`, `FocasDriver.cs:117`, `TwinCATDriver.cs:76`, `AbLegacyDriver.cs:74`, `S7Driver.cs:65`, `SqlDriver.cs:226`, `AbCipDriver.cs:157` - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (PollGroupEngine tests file) + one per-driver wiring pin is NOT required (see below) **Spec.** The seven poll drivers' pacing change currently ships with no production consumer of `PollGroupEngine.OverrunCount` (the accepted-not-fixed item). Close it the way the engine's own doc note (`PollGroupEngine.cs:47-50`) prescribes: the driver wires its own meter to the existing `onOverrun` callback (ctor param, line 105). 1. Add to `OtOpcUaTelemetry` (following the existing `CreateCounter` field pattern, e.g. `otopcua.driver.lifecycle` at line 42): `public static readonly Counter PollOverruns = Meter.CreateCounter("otopcua.driver.poll.overrun", unit: "{overrun}", description: "Poll cycles that ran longer than the poll interval (slow success and slow failure both count — see PollGroupEngine.NoteOverrun).");` 2. In each of the seven drivers, pass `onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, new KeyValuePair("driver.type", ""))` where `` is the driver's `DriverTypeNames` constant (use the constant, not a literal — the `TwinCat`/`Focas` drift was a real bug once). If the constructor scope has a cheap device/instance identifier already in hand, add a second `device` tag; do NOT plumb new state to get one. Note: Commons is referenced from driver projects already via the messages namespace — if any of the seven driver csproj lacks the `Commons` reference, add the ProjectReference; report it rather than inventing a per-driver Meter (Galaxy's own-meter pattern is the outlier, not the template). 3. Doc notes in `PollGroupEngine.cs` (comment-only): - Update lines 47-50: the follow-up is now delivered driver-side via `OtOpcUaTelemetry.PollOverruns`; the engine still deliberately owns no Meter. - `NoteOverrun` (lines 315-322): add the caveat that it conflates a slow success with a slow failure — both increment; disambiguation would need the loop's outcome, and no consumer needs it yet. - `backoffCap` ctor param (line 104): doc its units (a `TimeSpan` cap on the exponential failure backoff) and its interaction with `minInterval` (backoff replaces, not adds to, the interval on the failure path — state whichever the loop at 261-263 actually does; read it, don't guess). **Tests.** One unit test on the engine pinning that an overrun invokes the `onOverrun` callback exactly as many times as `OverrunCount` increments (may already exist from Task 11 of the remainder — if so, extend or cite it; do not duplicate). The seven driver wirings are one-line closures over a static counter — no per-driver test; a wiring test would just restate the line. **Step order:** test (or cite existing) → counter → 7 wirings → doc notes → build the solution → commit. **Commit:** `feat(drivers): per-driver poll-overrun meter on the shared OtOpcUa Meter; NoteOverrun + backoffCap doc notes` --- ### Task 2: S7 span-option validation guard **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Tasks 1, 3, 4, 5, 6, 7, 8, 10, 11 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs` (`ParseOptions`, line 46 area) - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` (doc remark only, if needed) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` (the factory/options parse test file) **Spec.** `SpanReadMaxSpanBytes` (`S7DriverOptions.cs:128`, default 220) has no validation: a nonsensical value is accepted at config time and only fails (or silently misbehaves) at read time. There is no `IValidateOptions` machinery for S7 (confirmed) — `ParseOptions` in `S7DriverFactoryExtensions.cs` is the single choke point, so validate there: - `SpanReadMaxSpanBytes < 1` → throw `ArgumentException`/config-error consistent with how `ParseOptions` reports other bad config (read the method; match its existing error style). - `SpanReadMaxGapBytes < -1` → same (−1 = disabled is legal, per `S7Driver.cs:571`'s `>= 0` enable check; 0 is legal "no gap tolerated"). - Values **above** the negotiated PDU size cannot be checked here — the negotiated size is opaque inside S7.Net (confirmed: no API exposes it) and S7.Net transparently chunks oversized reads. Do NOT invent a fake upper bound; instead add one doc line to the `SpanReadMaxSpanBytes` remark in `S7DriverOptions.cs` stating that over-PDU values are legal but degrade to multi-request chunking inside S7.Net. **Tests.** Red-first: `SpanReadMaxSpanBytes = 0` rejected; `SpanReadMaxGapBytes = -2` rejected; `-1`/`0`/defaults accepted. **Commit:** `fix(s7): validate span-read options at parse time; document the over-PDU degradation` --- ### Task 3: Historian drain — shutdown cancellation is not a per-tag failure **Classification:** high-risk (concurrency, #538 quarantine semantics) **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 1, 2, 4, 5, 6, 7, 8, 10, 11 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs:703-712` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs` **Spec.** In `DrainOnceAsync`, the per-tag parallel lambda wraps `_writer.WriteLiveValuesAsync(g.Key, g.Values, ct)` in a **bare `catch`** (`:707-712`) that converts a shutdown `OperationCanceledException` into `ok = false`. When sibling tags in the same pass already succeeded, the #538 logic (`:775-784`) then counts the cancelled tag as a consecutive per-tag failure — a shutdown can push a healthy tag toward abandonment. The outer `catch (OperationCanceledException) when (ct.IsCancellationRequested)` (`:812-816`) already treats whole-pass cancellation as non-fault but is never reached because the inner catch eats it first. Fix: in the per-tag lambda, rethrow genuine token cancellation ahead of the bare catch: ```csharp catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { ok = false; } ``` The rethrow propagates through the awaited per-tag task to the pass's `await` and lands in the existing outer catch → `DrainResult(true, 0, 0)`, no ack, no failure counts. Verify the `SemaphoreSlim window` release still runs on this path (it is in a `finally` — confirm, and keep it that way). Do NOT touch the #538 discriminator ("did another tag write this pass"), the all-or-nothing ack, or the falsification control's `succeeded == 0` keying. **Tests.** Red-first: a writer fake where tag A completes successfully, then the token is cancelled and tag B's write throws `OperationCanceledException(ct)`. Assert: the pass does not ack (outbox not truncated), and — the load-bearing assertion — tag B accumulates **no** failure count (observe via the same seam the existing #538 tests `A_permanently_failing_tag_is_abandoned...` (`:408`) and `A_backend_that_fails_every_tag_abandons_nothing` (`:456`) use: run `MaxWriteAttempts` shutdown-cancelled passes interleaved with sibling successes, then show the tag is NOT abandoned). Also keep a control: a non-cancellation throw from B with A succeeded still counts (pins that the fix is scoped to genuine `ct` cancellation — an OCE thrown while `ct.IsCancellationRequested == false` must still count as a failure, which the `when` filter gives for free; test that too). **#491 hazard note:** this file is the `ConfigureAwait(false)`-destroys-`Self` site. The fix touches only the catch clauses inside an existing async lambda — do not add `ConfigureAwait` changes, do not touch `Self` usage. **Commit:** `fix(historian): a shutdown cancellation mid-drain is a pass abort, not a per-tag failure` --- ### Task 4: Direct-apply artifact read — explicit command timeout **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Tasks 1, 2, 3, 5, 6, 7, 8, 10, 11 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:2082-2107` (`LoadArtifactBlob`) - Test: the DriverHostActor direct-apply test file (locate the Task 26 tests around `BeginDirectApply`/`ArtifactForApply` in `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/`) **Spec.** The Task 26 Important: `LoadArtifactBlob` (EF read of `Deployment.ArtifactBlob`, runs in `Task.Run` off the mailbox) carries no timeout or cancellation. The code relies on an implicit bound and does not assert it. Confirmed by exploration: nothing sets a command timeout anywhere on the ConfigDb path (`ServiceCollectionExtensions.cs:30` has no `CommandTimeout`, no connection string in the repo carries `Command Timeout=`), so the only bound is SqlClient's default 30 s command timeout. Fix — make the bound explicit and local (do NOT change the global pooled-factory options; that would silently re-time every ConfigDb consumer, including migrations and long admin queries): 1. In `LoadArtifactBlob`, after `CreateDbContext()`: `db.Database.SetCommandTimeout(TimeSpan.FromSeconds(30));` with a comment: this read runs off the mailbox on a pool thread with no CancellationToken (the actor cannot be wedged, but a hung SQL read would pin a pool thread); the explicit timeout replaces reliance on SqlClient's default so a future connection-string or provider-default change cannot unbound it. A timeout surfaces as the existing catch-to-null path (`:2093-2106`) → the apply fails, revision does not advance — the #485-family "no answer" semantics, already correct. 2. Do not thread a `CancellationToken` — `FirstOrDefault` would need to become async and the only caller-side token is actor lifetime; the timeout gives the same bound without touching the actor seam. State this in the comment. **Tests.** The behaviour delta is not black-box observable without a hung SQL server, so: pin with a source-level test only if the repo already has the source-scan pin pattern for this file; otherwise a unit test that `LoadArtifactBlob`'s failure path (throwing DbContext) still returns null and fails the apply (may already exist — cite it), plus the comment. Say honestly in the report which it was. **Commit:** `fix(runtime): explicit 30s command timeout on the Direct-apply artifact read` --- ### Task 5: SessionAclDecisionCache — eviction race on `_globalCount` + stress test **Classification:** high-risk (lock-free concurrency) **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 1, 2, 3, 4, 6, 7, 8, 10, 11 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs:178-182` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs` **Spec.** The per-shard eviction branch (`Set()`, lines 178-182) runs `shard.Entries.Clear()` then `Interlocked.Add(ref _globalCount, -Interlocked.Exchange(ref shard.ApproxCount, 0))` as two non-atomic steps. A concurrent `Set` on the same shard can insert into the just-cleared dictionary and increment both counts between them; the evictor's `Exchange` then subtracts a count that includes the racer's fresh insert → `_globalCount` under-counts (negative-only drift; trims early, never late — correctness of decisions is unaffected, which is why this was deferred). Fix by making the eviction atomic with respect to concurrent inserts: **replace the shard object** rather than clearing the shared dictionary — the same pattern `ClearAll()` already uses (it drops shard objects wholesale). Read the shard-storage shape first (how shards are held per session key) and swap a fresh `Shard` in with a CAS/indexer replace, then subtract the *displaced* shard's `ApproxCount` from `_globalCount`. A racer that inserted into the displaced shard loses its entry (acceptable — it re-caches on next miss) but its global increment is subtracted with the shard it landed in, so the count cannot over-subtract. Preserve: the outer-bound gate at `:171`, generation-rollover `ClearAll()` at `:238`/`:250`, and the "increment only on genuine `TryAdd`" invariant at `:186-189`. **Tests.** Red-first is impractical for a race; do it as: (1) a deterministic interleaving test if the seams allow (inject at the eviction boundary), else honestly a stress test — N writer threads hammering one session's shard past `MaxEntriesPerSession` while others insert, barrier-started, several rounds; assert at quiescence `_globalCount` equals the actual live entry total (expose via an internal count-audit helper or reflection over shards) and is never negative. Run the stress test with the fix reverted (Edit-revert, then restore) and report whether it goes red — if the race won't reproduce under test load, say so honestly; the deterministic invariant audit is then the pin. **Commit:** `fix(acl): shard eviction swaps the shard object so _globalCount cannot over-subtract; concurrency stress test` --- ### Task 6: Revalidate memo — reject empty node identifiers **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Tasks 1, 2, 3, 4, 5, 7, 8, 10, 11 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:3319-3321` (`IsSubscriptionStillPermitted`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs` **Spec.** The pass-local revalidation memo key (`:3327`) tolerates an empty string identifier: the guard at `:3319-3321` rejects only a *non-string* identifier, so all items on empty-`s=` nodes for one (session, realm) collapse onto one memoized verdict. `DataPlaneAuthorizer.Evaluate` already rejects exactly this at `DataPlaneAuthorizer.cs:253` (`identifier.Length == 0`). Mirror it: extend the guard with `|| identifier.Length == 0` so empty identifiers fall through to the un-memoized `IsAllowedCached` call at `:3323-3324` (fail-safe: un-memoized means each item gets its own authorizer verdict, and the authorizer's own guard then handles the empty id). **Tests.** Red-first in `NodeManagerStage6EnforcementTests` (it already drives `RevalidateMonitoredItems` directly at `:484`/`:515`): two monitored items on nodes with empty string identifiers, different underlying permit outcomes by whatever seam the tests use — assert they do NOT share one memoized verdict after the fix. If distinct outcomes for two empty-id nodes aren't constructible through the fake, pin instead that empty-id items bypass the memo (e.g. authorizer-call count equals item count for empty ids, vs. 1 for a memoized duplicate pair) — state which shape was used. **Commit:** `fix(acl): revalidation memo rejects empty node identifiers, mirroring the authorizer's guard` --- ### Task 7: SQL-Server-backed tests for the drift probe + rename script scan **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 8, 10, 11 **Files:** - Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs` - Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs` - Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs:1451` (one comment line) - Modify (if needed): `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (`Microsoft.Data.SqlClient` if not already transitive) **Spec.** Two register items say the same thing: EF InMemory does not exercise the SQL Server provider's translation, so (a) the Task 17 `GroupBy(_ => 1)` watermark aggregate (`DeploymentDriftProbe.ReadWatermarkAsync`, `DeploymentDriftProbe.cs:122-135`, over **`ConfigEdits`** — not deployments; the folder name misleads) and (b) the Task 21 expression-built `SourceCode.Contains` OR-chain (`RawTreeService.AnyPathContained`, `:1488-1513`) are unverified where they matter. 1. **Fixture**: copy `SchemaComplianceFixture`'s shape (`tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs`): env `OTOPCUA_CONFIG_TEST_SERVER` (default `10.100.0.35,14330`) + `OTOPCUA_CONFIG_TEST_SA_PASSWORD` (default `OtOpcUaDev_2026!`), unique per-run DB name, `Database.Migrate()`, drop-on-dispose, collection fixture. **One deliberate deviation**: probe reachability first (short TCP/`SqlConnection` attempt) and expose a `SkipReason`; tests call `Assert.Skip` when unreachable (the `Snap7ServerFixture.cs:41-92` pattern) — AdminUI.Tests must stay green offline, unlike Configuration.Tests which fails hard. Document the deviation in the fixture doc comment. 2. **Drift probe test**: seed `ConfigEdits` rows with known timestamps, run the real probe against the SQL-backed context factory, assert `LastEditUtc`/`EditCount` — proving the `GroupBy(_ => 1)` aggregate translates and returns the right watermark (a translation failure throws; wrong translation returns wrong values — both caught). 3. **Rename scan test**: seed a `Script` whose `SourceCode` contains a tag path in **different case** than the rename target. On SQL Server's default case-insensitive collation the scan must find it (the "collation widening" the InMemory provider cannot show — InMemory `string.Contains` is case-sensitive). Assert the warning names the script. Also one same-case control. Exercise through the real `BuildScriptReferenceWarningAsync` (`RawTreeService.cs:1437-1470`) with > 1 batch if cheap (201 paths) to also cross the chunk boundary against the real provider. 4. **Comment**: one line at the batching loop (`RawTreeService.cs:1451`): worst case is ~250 sequential round-trips for a rename touching the 50k-path scale (200-term chunks); deliberate — bounded memory beats one giant OR-chain. **Commit:** `test(adminui): SQL-Server-backed pins for the drift watermark aggregate and the rename scan's collation widening` --- ### Task 8: ScriptedAlarmEngine — TrackBackgroundTask ordering + volatile `_disposed` **Classification:** high-risk (concurrency, disposal) **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 10, 11 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` (`TrackBackgroundTask` `:996-1006`, `_disposed` `:172`, call sites `:559`, `:989`) - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs` **Spec.** Two recorded gaps: 1. **Registration-ordering**: callers evaluate the async-method argument (which starts the work) *before* `TrackBackgroundTask` adds it to `_inFlight` (`TrackBackgroundTask(DrainDirtyAsync(...))` at `:559`). `Dispose` can snapshot `_inFlight` (`:1090-1094`) in that window and drain past a genuinely in-flight task. Fix by removing the window, not shrinking it: change the call shape to register-first — e.g. give `TrackBackgroundTask` a `Func` overload that (under `_inFlightLock`) installs a placeholder/`TaskCompletionSource` (or checks `_disposed` and declines to start), then invokes the factory and links completion — so a task that runs is always visible to `Dispose`'s snapshot, and after `_disposed` no new background work starts. Preserve exactly: the drain-latch handover semantics (`Sustained_synchronous_drain_failures_hand_over_without_deepening_the_stack`, `:1627` — the handover MUST keep spawning via `Task.Run` on a fresh stack, commit `c8a70143`), the `ExecuteSynchronously` self-removal, and `Dispose`'s synchronous `WhenAll` drain. If the handover call site (`TrackBackgroundTask(Task.Run(() => DrainDirtyAsync(ct)))`) needs reshaping to the factory form, keep the `Task.Run` inside. 2. **`_disposed`**: make it `volatile` (`:172`) — it is written in `Dispose` (`:1078`) and read unsynchronized on other threads (`:235, 544, 603, 662, 682, 987, 1027`). **Tests.** Red-first for the ordering fix where constructible: a factory whose task completes synchronously/immediately while `Dispose` races — deterministically, pin the new invariant instead: (a) after `Dispose`, `TrackBackgroundTask`'s factory form does not invoke the factory (no new work post-disposal); (b) a task started via the factory form is awaited by `Dispose` even when it completes before the continuation attaches. Every existing engine test (`:817`, `:1458`, `:1521`, `:1563`, `:1627`) must stay green — they are the spec for what must not change. **Commit:** `fix(alarms): background tasks register before they start; volatile _disposed` --- ### Task 9: Test-reliability hardening (durability second-reader, #491 controls) **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Tasks 4, 5, 6, 7, 8, 10, 11 (NOT Task 3 — same recorder test file) **Blocked by:** Task 3 **Files:** - Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs:172-197` (+ helper `ReadCommitted` `:203+`) - Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs:359-387`, `:797-831` (doc comments only) **Spec.** 1. `AppendBatch_group_commit_is_durable_before_dispose` depends on filesystem flush visibility to a second `FasterLog` reader, which macOS does not guarantee on the Windows/Linux timescale. Prefer a robust wait: retry the second-reader scan (re-open or re-read up to `CommittedUntilAddress`) with a bounded poll (e.g. up to 5 s) before asserting — the property under test is "durable before dispose", which a bounded wait still proves (the writer is not disposed during the poll). Only if FasterLog's reader genuinely cannot observe later flushes on re-scan, fall back to an OS-conditional `Assert.Skip` on macOS with a comment naming the platform limitation. Report which. 2. The two #491 yielding-fake tests: ensure each carries a comment stating the falsification control is nondeterministic under load (ThreadStatic actor cell) — a single green run of the intended-red mutation is not proof the regression is caught. The `:790-793` comment covers part of this; make both tests carry the load-nondeterminism warning. **Commit:** `test(historian): bounded-wait durability read; honest nondeterminism notes on the #491 controls` --- ### Task 10: MTConnect `Build` streams through XmlReader **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 8, 11 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs` (`Build` `:221`, the three `Parse` overloads `:102/:138/:155`) - Test: the existing MTConnect parser test file(s) under `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/` **Spec.** Task 27 removed the per-chunk string copies; `Build(XDocument)` still materialises the whole document tree per sample chunk. Rewrite the parse path to stream via `XmlReader` (`XmlReader.Create(stream, settings)` with DTD prohibited, async off — match the sync call shape) extracting exactly what `Build` extracts today, and route all three `Parse` overloads through it (the string overload via a `StringReader`). Requirements: - **Byte-for-byte behavioural parity** with the current `Build` on every existing test — the current suite is the spec. Namespace handling: MTConnect documents version their namespace URI; whatever name/namespace matching `Build` does today (local-name-based or namespace-qualified) must be preserved exactly — read `Build` first and mirror it. - Keep the `AsStream` zero-copy path (`:216-219`) feeding the reader. - If, mid-implementation, `Build`'s extraction turns out to lean on document-order queries that make a single-pass reader genuinely intractable (e.g. backward references), STOP and report rather than shipping a half-streamed hybrid — that finding would itself close the register item as "not worth it". **Tests.** The existing suite must pass unchanged (parity). Add one test only if a gap is found in existing coverage of odd shapes (empty streams element, condition entries, unknown elements skipped) — cite, don't duplicate. **Commit:** `perf(mtconnect): stream sample-chunk parsing through XmlReader; no per-chunk document tree` --- ### Task 11: IS7Plc multi-variable read seam **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 8, 10 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs` (interface `:18-66`, `S7PlcAdapter` `:125-149`) **Spec.** S7.Net 0.20.0 ships `Plc.ReadMultipleVarsAsync(List, CancellationToken)` (confirmed in the shipped DLL); `IS7Plc` does not expose it. Add `Task> ReadMultipleVarsAsync(List items, CancellationToken ct)` to the interface and implement in `S7PlcAdapter` delegating to the library, wrapped in `S7OperationDeadline.RunAsync` exactly like the existing members (`:126-149` pattern — mirror it, including the deadline/exception shape). Return the same `DataItem` list S7.Net populates. No driver-side use yet (Task 12). Update any test fakes implementing `IS7Plc` (they will fail to compile otherwise — implement as `throw new NotSupportedException` in fakes that don't care, or a delegate hook in the shared fake if one exists). **Tests.** Compile-level only for the adapter (it is a one-line delegation under the same deadline wrapper the other members already have pinned); the behaviour tests come with Task 12's fake-driven driver tests. State this honestly. **Commit:** `feat(s7): expose ReadMultipleVarsAsync through the IS7Plc seam` --- ### Task 12: S7 multi-var reads for span leftovers **Classification:** high-risk (wire behaviour, partial-failure semantics) **Estimated implement time:** ~6 min (accepted over budget — do not split further; the fallback semantics need one head) **Blocked by:** Tasks 1, 11 (both touch `S7Driver.cs` / `IS7Plc.cs`) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (the `perTag` leftover loop, `:602-614` area) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` (extend `S7SpanReadParityTests.cs` or sibling) **Spec.** The Task 13 planner reads one PDU per span; leftovers (`plan.LeftoverIndexes`: Timer/Counter tags, unparseable addresses, `PrefersPerTagRead` singles) still go one round-trip each. Batch the *batchable* leftovers through the new `IS7Plc.ReadMultipleVarsAsync`: - **Partition** leftovers into DataItem-expressible vs not. Timer/Counter: S7.Net's `DataItem`/`VarType` does support Timer/Counter — verify against the library types and include them only if the driver's existing per-tag path for them maps cleanly onto a `DataItem`; if the mapping is not obviously identical, leave Timer/Counter on the per-tag path and say so (correctness beats the round-trip). Unparseable addresses stay per-tag by definition. - **Chunk** conservatively: a multi-var request's item headers consume PDU space (~12 bytes/item request-side against the common 240-byte PDU) and S7.Net does NOT chunk multi-var reads for you. Use a `const int MultiVarReadMaxItems = 12` with a comment deriving the number; no new config key (YAGNI — the planner options weren't config either until they were needed). - **Fallback, per chunk**: if `ReadMultipleVarsAsync` throws for a chunk, fall back to the existing per-tag `ReadOneAsync` path for that chunk's tags — a multi-var failure must never make tags Bad that a per-tag read would have served. Only per-tag failure produces the Bad snapshot (reuse `RecordReadFailure` semantics). Per-item errors inside a successful multi-var response (S7.Net surfaces item error codes) likewise fall back per-tag for the failed items only, if the library exposes them; read the library's `DataItem` result shape and handle what it actually gives you. - Results must sort back into batch order exactly as today (`:613`). - Health semantics unchanged: the same success/failure accounting the per-tag loop feeds. **Tests.** Fake `IS7Plc` driven, red-first: (1) N batchable leftovers issue `⌈N/12⌉` multi-var calls, zero per-tag calls; (2) parity — values decoded from multi-var equal the per-tag path's (mirror `S7SpanReadParityTests` structure); (3) a throwing multi-var chunk falls back to per-tag and serves Good; (4) per-tag-only tags (Timer/Counter if excluded, unparseable) untouched; (5) falsification: break the chunk size (mutate to 1000) or the fallback and show the corresponding test goes red, then revert via Edit. **Commit:** `perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback` --- ### Task 13: S7 live gate — span planner + multi-var against the s7_1500 fixture **Classification:** standard (integration test + live run) **Estimated implement time:** ~6 min including the live run **Blocked by:** Tasks 2, 12 **Files:** - Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs` **Spec.** The register's ⚠️: the 16/220 span defaults are analytically chosen and never live-gated. Add fixture-backed tests in the established pattern (`[Collection(Snap7ServerCollection.Name)]`, `[Trait("Category","Integration")]`, `[Trait("Device","S7_1500")]`, `Assert.Skip(sim.SkipReason)` when unreachable — see `S7_1500SmokeTests.cs:16-24`, fixture `Snap7ServerFixture.cs:40-92`, endpoint env `S7_SIM_ENDPOINT` default `10.100.0.35:1102`): 1. Seed (via whatever helper the existing smoke tests use to seed DB bytes) a tag layout that genuinely exercises the planner: several tags in one DB within the 16-byte gap (one span), a tag beyond the gap (second span or leftover), and ≥ 2 leftover-class tags (to exercise the Task 12 multi-var path against the real wire). 2. Read the batch with span reads enabled (defaults) and again with spans disabled (`SpanReadMaxGapBytes = -1`); assert value-for-value parity and all-Good quality. 3. **Run it live** against the fixture: probe `10.100.0.35:1102` first; if down, bring the stack up over SSH (`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose --profile s7_1500 up -d'` — the Mac path per CLAUDE.md; `lmxopcua-fix` does not exist here). Then `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~S7_1500SpanRead"`. The gate PASSES only on a real run (skipped-because-unreachable is not a pass — report honestly). Record the result in the test file header comment and the task report. **Commit:** `test(s7): live-gate span reads + multi-var leftovers against the s7_1500 fixture` --- ### Task 14: docker-dev live gate (regression of the merged remainder) **Classification:** standard (no code — live verification) **Estimated implement time:** ~10 min wall (mostly rig startup) **Blocked by:** Tasks 1-12 (gates the branch's code state) **Files:** none (evidence appended to this plan's Outcomes) **Spec.** The remainder branch merged with **no live gate** (accepted at the time; recorded as a release condition). Run the bounded regression gate on the local docker-dev rig (OrbStack — the rig runs locally on this Mac; only fixtures live on 10.100.0.35): 1. `docker compose -f docker-dev/docker-compose.yml up -d --build` (builds the current branch image); wait for `cluster-seed` "done." (`docker-dev/README.md:71-77`). 2. Verify all nodes healthy; AdminUI at `http://localhost:9200` (login disabled — drive it directly). 3. Deploy: headless `POST http://localhost:9200/api/deployments` with `X-Api-Key: docker-dev-deploy-key`; confirm the deployment seals green (all nodes acked). 4. Data plane: Client.CLI `browse` + `subscribe` against `opc.tcp://localhost:4840`; confirm values stream Good. Make a config edit that produces a surgical apply, deploy, and confirm the open subscription survives (the 2026-08-15 gate's step 3, reduced). 5. `/alerts` and `/hosts` pages load and stream (exercises the Fleet/Hosts poll-guard and telemetry changes). 6. Tear down: `docker compose -f docker-dev/docker-compose.yml down -v`. Out of gate scope (say so in the evidence): historian end-to-end (needs VPN/real gateway), FetchAndCache flip, load-scale throughput. Any failure: STOP, report, do not "fix the rig" around a product defect. **Commit:** none (evidence lands in Task 15's docs commit). --- ### Task 15: Close-out — full sweep + Outcomes **Classification:** small **Estimated implement time:** ~5 min plus sweep wall time **Blocked by:** all prior tasks **Files:** - Modify: this plan (append `## Outcomes`: per-task commit SHAs, deviations, the two live-gate evidence blocks) - Modify: `docs/plans/2026-08-17-perf-followups.md.tasks.json` (final ledger) - Modify: `docs/plans/2026-08-15-perf-roadmap-remainder.md` (tick the closed register items — a one-line "closed by , 2026-08-17" annotation per item; do NOT rewrite history) - Modify (if any behaviour docs drifted): `docs/Historian.md`, `docs/ScriptedAlarms.md`, `CLAUDE.md` (only the lines these tasks invalidate — e.g. the "no production consumer of the overrun counter" caveat) **Spec.** (1) `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — 0 errors. (2) Full unit sweep across all test projects (the remainder plan's §Task 28 procedure; DB suites against `10.100.0.35,14330` if reachable, recorded either way; the two empty Wonderware test dirs are glob noise, not failures). (3) Outcomes + register annotations + ledger. (4) Memory update is the orchestrator's job, not this task's. **Commit:** `docs(plan): perf follow-ups close-out — outcomes, register annotations, sweep record` --- ## Outcomes *Written at close-out, 2026-08-17, on branch `perf/followups`. All 15 tasks complete; both release-condition live gates PASSED.* ### Sweep record `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 errors**, 896 warnings (all pre-existing: `xUnit1051` cancellation-token advice and the repo's own `OTOPCUA0001` CapabilityInvoker analyzer firing on test call sites). Full unit sweep — every `*.Tests` project in the solution, run per-project with `--no-build` against the tree at the close-out commit. `*.IntegrationTests` are build-only by convention and were not run in the sweep (the S7 suite ran separately as Task 13's live gate; the two empty `tests/Drivers/…Driver.Historian.Wonderware*.Tests` directories carry no csproj and are glob noise). **43 / 43 test projects green (exit code 0 on every one).** | | | |---|---| | Passed | **10 358** | | Failed | **0** | | Skipped | 19 | | **Total** | **10 377** | No test failed, so no isolation re-run was needed. All 19 skips are **pre-existing, env-gated** suites, spread across five projects — `Core.Tests` (1), `Driver.Galaxy.Tests` (8), `Driver.Historian.Gateway.Tests` (6, the `LiveIntegration` category that needs the `wonder-sql-vd03` VPN), `OpcUaServer.Tests` (1), `Runtime.Tests` (3). **None of the skips are the new SQL-Server-backed tests**: `AdminUI.Tests` ran **1537 / 0 skipped** and `Configuration.Tests` **190 / 0 skipped**, which is the evidence that Task 7's pins actually executed against a real SQL Server rather than skipping past themselves. The DB-backed suites (`Configuration.Tests`, `AdminUI.Tests` including Task 7's new SQL-Server-backed pins) ran **against a reachable** `10.100.0.35,14330` — they were executed, not skipped. None of the known load-flake candidates flaked (`DriverInstanceActorCommsLossWatchdogTests`, `RawTreeServiceRenameWarningTests`, `RouteNativeAlarmAck_on_non_primary_is_dropped`, plus this branch's new `SessionAclDecisionCache` concurrency stress test and the `ContinuousHistorizationRecorder` timing tests). ### Per-task outcomes | # | Task | Commit(s) | Outcome + deviation | |---|---|---|---| | 1 | Per-driver poll-overrun meter + `PollGroupEngine` doc notes | `e22ecea0`, `97902d68` | Done. Review deviation: the tag keys were literals; moved to snake_case consts (`driver_type` / `driver_instance`) so they match the rest of the Meter and a dashboard's group-by cannot split across two spellings. | | 2 | `SpanReadMaxSpanBytes` validation guard | `acb37724` | Done, as planned — validated at parse time, with the over-PDU degradation documented. | | 3 | Historian drain rethrows a shutdown `OperationCanceledException` | `7870c91f` | Done. Deviation in *severity*, not shape: the red run showed the bug did not merely inflate `_tagWriteFailures` — a shutdown mid-drain **discarded healthy tags' samples**. A pass abort, not a per-tag failure. | | 4 | Bound the Direct-mode `LoadArtifactBlob` read | `dba5f806` | Done, with one addition the plan did not anticipate: an `IsRelational()` guard, because the extension throws on the InMemory provider the actor tests use. EF 10.0.7 verified **empirically** to reset the timeout on return to the pool, so the local `SetCommandTimeout` cannot pollute the pooled factory. ⚠️ The relational branch is consequently untested in-suite. | | 5 | ACL `_globalCount` drift + concurrency stress test | `d8849345` | Done — **beyond the planned scope, and provably so.** The planned counter swap alone was insufficient; a seal sentinel *and* a `TryBookInsert` compare-exchange were both required. Falsification: removing either went 6/6 red. | | 6 | Revalidation memo rejects empty node identifiers | `c4d8e4a2` | Done as a pattern-form guard mirroring the authorizer's; the bypass is pinned by a clock-call count rather than by observing the memo directly. | | 7 | SQL-Server-backed pins for the drift watermark + rename collation | `c149410b` | Done. Deviation: `AdminUI.Tests` is pinned to **xunit v2** by `Akka.TestKit.Xunit2`, so `Assert.Skip` is unavailable — an overriding `SqlServerFactAttribute` gives a genuine skip instead. Both the `GroupBy` watermark aggregate and the `CHARINDEX` collation widening confirmed **live** against SQL Server. | | 8 | `TrackBackgroundTask` registration ordering + volatile `_disposed` | `5ae43023` | Done via a TCS-placeholder **register-first** factory (registration precedes the work starting, closing the gap at its root rather than narrowing it); `_disposed` made volatile. | | 9 | Durability-test robustness + honest #491 nondeterminism notes | `6b46c485` | Done. Deviation: the planned macOS skip proved **unnecessary** — verified empirically that a bounded wait is sufficient, so the test runs on macOS rather than skipping. The #491 falsification control's nondeterminism is inherent and is now documented, not fixed. | | 10 | MTConnect sample-chunk parsing through `XmlReader` | `cda82c07` | Done. 566/566 parity against the pre-change parser, ~19% allocation win. One **disclosed** behaviour change: on a doubly-broken document the *ordering* of the reported errors differs. | | 11 | Expose `ReadMultipleVarsAsync` through the `IS7Plc` seam | `6ee553cf` | Done, as planned. | | 12 | S7 span leftovers batch through multi-var read PDUs | `20022083` | Done, with two forced deviations: S7.Net throws for the **whole** multi-var request rather than per item, so a bad item is handled by a **per-chunk fallback**; and Timer/Counter areas are **excluded** because S7.Net boxes them as `int` where the single-read path yields `ushort`, and box parity is load-bearing downstream. | | 13 | S7 live gate (span reads + multi-var leftovers) | `57c66416` | **LIVE GATE PASSED** vs `10.100.0.35:1102`. Evidence below. | | 14 | docker-dev live gate (regression of the merged remainder) | *(no commit — evidence below)* | **LIVE GATE PASSED.** Evidence below. | | 15 | Close-out — full sweep + Outcomes | `7c5a843a`, *this commit* | Done. `7c5a843a` carries the review round's comment-only fixes; this commit carries the Outcomes, the live-gate evidence, the register annotations, and the ledger. | ### Live gate — S7 span reads (Task 13) **2026-08-17, against the `s7_1500` fixture at `10.100.0.35:1102`. PASSED.** - **Results:** 3/3 span-read tests green; 7 passed / 1 skipped across the full `Device=S7_1500` suite. The single skip is the env-gated SYN-blackhole test, which needs a recipe the fixture does not provide — not a silent skip of the gated behaviour. - **Wire shape (the actual claim under test):** with spans **on**, 10 tags produced **3 requests** — 2 block reads + 1 multi-var read carrying 3 items. With spans **off**, the same 10 tags produced **10 requests**. Multi-variable and cross-area batching both confirmed on the wire, and a falsification bit was flipped to prove the counter was measuring the real traffic. - **Reconnect suite** ran **live** (not simulated) via `S7_RECONNECT_BOUNCE_CMD` and passed. - **Not live-pinned:** the Timer/Counter exclusion — the fixture has no TM/CT area, so the box-parity reasoning behind excluding them rests on unit tests only. - **Ops notes for whoever re-runs this** (both cost time on this run): - The fixture **profile is baked into the image**, so `--build` is mandatory after editing a profile. Confirm the seed actually took by looking for `seeded DB1 … seeds=` in `docker logs`; a stale image seeds the old profile silently. - `/opt/otopcua-s7` on the docker host is **root-owned**, so a plain `rsync` fails with status 23 **while looking successful** in its summary output. Use `--rsync-path="sudo rsync"`. ### Live gate — docker-dev regression (Task 14) **2026-08-17, local OrbStack rig, branch at `6b46c485`, ~14.5 min wall. PASSED.** - **Boot:** clean-slate build + boot; 8 containers healthy, `RestartCount=0` across the board. - **Deploy:** `c50678b2` sealed in **306 ms** with **6/6 Applied**. - **Data plane:** browse / read / write end-to-end against the pymodbus fixture; a **180 s** subscription streamed with **0 Bad** values. - **Surgical apply (the headline regression target):** a **real** UNS-reference surgical apply — deploy `3df4b321`, logging `applied plan kind=PureAdd added=2 changed=1 surgicalRawTags=1 rebuild=False`. The open subscription streamed **Good across the apply**, and the newly-added UNS mirror served live immediately. This is the behaviour the 2026-08-15 surgical-deploys work exists to protect, exercised as a genuine surgical path rather than a rebuild in disguise. - **Deploy gate:** correctly rejected a malformed first attempt with **422** (`UnsSegmentInvalid`, `EquipmentIdNotDerived`) — the gate is live, not bypassed. - **AdminUI:** `/fleet`, `/alerts`, `/hosts`, `/uns`, `/raw`, `/deployments` all 200 with live data. - **Logs:** 0 ERR/FTL, excluding the by-design standby 503s. - **Teardown:** clean. - **Anomalies (non-blocking, all disclosed):** - A transient **404 on the first `POST /api/deployments`** ~40 s after boot; the retry succeeded. Scripted deploys should **poll** rather than fire once (see the follow-up register below). - Boot-time telemetry-dial **reconnect warnings**, settling within one cycle. - `ScriptLogEntry` **dead-letters on site nodes**, one per deploy — **pre-existing**, not introduced by this branch. - **NOT exercised (say so plainly, do not read the pass as wider than it is):** historian end-to-end (needs the VPN), the `FetchAndCache` flip, the ACL-**enabled** enforcement path, the alarm-bearing surgical **ratchet** to rebuild, load-scale throughput, and real-browser Blazor circuits. ### Follow-up register (opened by this round) Open items this branch surfaced but deliberately did not close. **None blocks the branch.** **ACL** - ⚠️ **Pre-existing generation-rollover window in `SessionAclDecisionCache.InvalidateIfStale`.** While the CAS winner is running `ClearAll()`, a racing caller can still serve a stale-generation verdict out of a shard the winner has not purged yet. The window is microseconds and **key revocation/expiry are unaffected** (both are re-checked *above* the cache on every call), so this is a stale *grant walk*, not a stale key. Fix shape if it ever matters: stamp the generation on `Shard` itself, or make the racer wait on the winner. - **Closed by `1979cf41` + `98d6a190` (r2).** Took the first fix shape — the generation is stamped on `Shard`, `TryGet` is generation-gated, and `Set` displaces a mismatched shard; `98d6a190` added the monotonic `<=` guard that stops a stale-generation straggler rolling the stamp backward. One benign residual remains (see r2's register). - **A fresh `Shard` could take an initial-capacity hint** for a session known to be crawling, to avoid the dictionary's growth rehashes on exactly the workload the per-key ceiling exists for. - **Closed by `1979cf41` (r2).** The eviction swap seeds the replacement shard with a capacity hint, gated on the generation matching. **S7** - **No cancellation-branch test on any read path.** Pre-existing, but this branch added a third path (multi-var), so the gap now spans three. - **Closed by `4f860733` (r2).** The span and multi-var cancellation reopen contracts are pinned (hang knobs + entered-TCS + `CreatedPlcCount`), and `120e885b` added the assertion that the stale handle is *disposed*, not merely replaced. - **No integration case for one bad decode inside an otherwise-good chunk** — the per-chunk fallback is unit-tested only. - **Closed by `4f860733` (r2).** One-bad-decode-in-chunk pinned (only that tag goes Bad, no fallback), alongside the short-answer shape mismatch (whole-chunk fallback). - **The Timer/Counter exclusion needs a live pin**, which needs a fixture with a TM/CT area. - **Closed by `fd202d81` (r2).** TM/CT areas added to the `s7_1500` fixture and the exclusion live-pinned against `10.100.0.35:1102` **at device 0**, the fixed point of snap7's BIT-address resolution and a real CPU's device-number addressing; the device-number leg (T5/C7) is env-gated on `S7_TIMER_NUMBER_ADDRESSING=1` as a ready-made real-CPU gate. **Historian** - **Task 3's `Task.Delay(500)` is still a hope-wait** and could become a bounded poll, the same treatment Task 9 gave the durability test. - **Closed by `45d67092` + `68461fbf` (r2).** All three fixed sleeps became bounded watches on real observables (`RemoveCallCount`, queue depth, `PeekBatchCallCount`), with the negative watch's cap anchored to a measured ~150 ms falsification trip. **AdminUI tests** - **A mid-run SQL connectivity blip surfaces as `SqlException` failures, not skips** — the reachability probe is one-shot, taken before the run. - **Documented wontfix (r2).** The one-shot probe is deliberate — a per-test probe would turn a genuine mid-run outage into silent skips; verified against the comment at the probe site and left as-is. - **Orphaned test databases have no reaper.** A killed test process leaves its uniquely-named database behind on `10.100.0.35,14330`. - **Closed by `8946644b` + `580b77ce` (r2).** A one-shot startup reaper drops >24 h-orphaned databases per fixture prefix; `580b77ce` fixed a review-found Critical (unvalidated name into interpolated `sa` DDL) with a GUID-suffix shape guard plus parameterized `QUOTENAME`/`sp_executesql`. **MTConnect** - **`AsStream` wants a buffer-ownership comment** so a future caller does not have to infer the lifetime from the call site. - **Closed by `1edfc263` (r2).** The doc comment now states the array-backed aliasing, the synchronous call-duration lifetime, and that the result holds no reference into the buffer. **docker-dev** - **The first `POST /api/deployments` after boot can 404.** Worth a readiness gate on the deployments endpoint, so scripted deploys do not have to encode a retry. - **Closed by `a61b3c98` + `9f09a62a` (r2)** — as a *gate plus instrumentation*, not a diagnosis: `deploy.sh` polls `/health/ready` (2 s/120 s) then POSTs once un-retried, and Traefik now runs `--accesslog` so the next occurrence names the matching router. **The 404's mechanism remains unconfirmed** until that log catches one.