Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
41 KiB
System Review Remediation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Resolve every finding from the 2026-10-21 8-slice Fable system review of OtOpcUa (master d51d35bd): 1 Critical, ~25 High, ~60 Medium, ~85 Low. These are memory/resource leaks, unimplemented features, performance limiters, and concurrency/health-contract hazards.
Architecture: The work is split into file-disjoint lanes. Each lane is executed by a subagent in its own git worktree off master, using TDD inside each task. Each lane gets an independent adversarial review by a model at least as strong as its implementer, then merges to local master in a fixed order. Lanes run in 4 waves. A lane only touches the files it owns. A finding whose fix needs a file another lane owns is sequenced into a later wave, not shared.
Tech Stack: .NET 10, Akka.NET, OPC Foundation UA-.NETStandard 1.5.378, EF Core/SQL Server, Roslyn scripting, Blazor Server, gRPC (mxaccessgw / HistorianGateway), xUnit + Shouldly.
Spec: the review reports. These are copied into the repo by Task 0 because the scratchpad is session-scoped:
docs/reviews/2026-10-21/00-SUMMARY.md … 08-historians-clients.md.
Finding refs below use R<report>:<id>. For example, R07:H10 is report 07, finding H10. Each finding in a report carries its location, evidence, failure scenario and a fix recipe, and that recipe is the starting design for its task.
Context
The user asked for a parallel review hunting memory leaks, unimplemented features and performance limiters. Eight Fable reviewers produced verified findings, and the user now wants all of them resolved. The review found 8 cross-cutting patterns (summary §"Cross-cutting themes"). The plan fixes each pattern once, then sweeps the per-driver instances:
- The runtime never disposes drivers. This is the root of the Critical Galaxy leak: each leaked gateway session pins an x86 worker for 30 min, so about 64 respawns exhaust
MaxSessions(mxaccessgw cross-check). - Timed-out scripts orphan a thread per evaluation.
- The health and watchdog contract is wrong in both directions across drivers.
- A plain
Dictionaryis mutated under live poll loops. - One device can stall every device in its driver.
- Array writes are advertised but unimplemented.
- Config fields are authored but never consumed.
- Mailbox-blocking
ReceiveAsynccalls.
Global Constraints
-
Worktree hygiene: every lane dispatch runs
git reset --hard <current master SHA>in its worktree and verifies it withgit merge-base --is-ancestor <SHA> HEAD, because worktrees start on stale commits. Each lane rebases on master before review. -
File ownership is binding: a lane edits only the paths in its "Owns" list. If a lane needs a change elsewhere, it creates an
F<N>follow-up task (memory: track deviations as follow-ups, don't ask) instead of editing. -
Fix the root, then the instance: where a pattern has a shared fix (Lane A disposes drivers, Lane E quarantines scripts), driver lanes must still make their own
ShutdownAsynca complete teardown. The runtime fix is a safety net, not a substitute. -
Health contract (from
.claude/rules/drivers.md+ #527/#611):- A deterministic device refusal is per-tag Bad, with a once-per-tag Warning and no health write.
- Comms faults write
Degradedand preserveLastSuccessfulRead. - Any data proving the device answered advances
LastSuccessfulRead. - Recovery dedup is on
(value, status), never value-only.
Every driver lane is reviewed against this checklist.
-
Re-init safety: device tables that are read by retained poll loops are build-and-swapped (
Volatile.Write) or madeConcurrentDictionary, never.Clear()ed in place. -
Array writes (user decision): encode
countelements at the resolved element width, refuse a length mismatch withBadOutOfRange, and keep the ScadaBridge wire shape. ScadaBridge writes CLR lists such asint[]to Int16 nodes, so the element type converts and is not exact-matched. -
Dead config fields (user decision): wire every one. Persisted JSON with unknown keys must keep loading.
-
Absence assertions need a positive control (memory). Any "no longer leaks / no longer publishes" test must also prove the positive path delivers.
-
AdminUI has no bUnit: Razor changes are live-verified on the docker-dev rig (
http://localhost:9200, login disabled) with Chrome automation, not unit tests alone. -
Historian paging changes require a paged-vs-unpaged fuzz test against a wire-faithful fake (memory, #692), re-run on every revision.
-
No remote commands on wonder boxes (no psbridge or winrm). Galaxy and AVEVA gates use network service access only, and any remote action is a userGate task for the user to run.
-
.claude/is untracked, so worktree agents cannot edit.claude/rules/*. They put suggested rule text in their final report, and the coordinator applies it in the main checkout. -
Subagents cannot write to the coordinator scratchpad. They return reports in their final reply.
-
Never commit secrets. Commits end with the session attribution line.
User decisions (already made):
- "Implement array encode now" for AbCip, AbLegacy and TwinCAT. Real-hardware proof is a userGate.
- "Wire them all" for every authored-but-unconsumed field: MQTT
birthObservationWindowSeconds, GalaxyDiscoverPageSize, Galaxy and OpcUaClientProbeTimeoutSeconds, ModbusFamily/MelsecSubFamily/DisableFC23,TelemetryDial:CallTimeoutSeconds,/auth/tokenJWT (finish it withAddJwtBearer), and theGetDiagnosticssnapshot (populate it with real data and give it a consumer). - "Gitea issues + local merge, push on OK": one issue per lane with findings as a checklist, merge to local master, and push/close only after the user OKs. The scadaproj index is updated on its
main.
Model assignment
| Tier | Model | Used for |
|---|---|---|
| frontier | fable | Actor lifecycle (A), OPC UA node manager/SDK (B), deploy coordinator (C1), script runtime + alarm actor restart (E1/E4), Galaxy (F), HistoryRead cap (H1). Fable reviews these lanes. |
| standard | opus | All driver lanes, control-plane remainder, Roslyn incremental analysis, sandbox, historian remainder. Opus adversarial review of every lane, including sonnet ones. |
| mechanical | sonnet | AdminUI UI fixes, CLI fixes, client library fixes, transport buffer pooling, docs/index edits, Gitea issue filing |
| — | haiku | not used; every task here needs code judgement |
Waves and parallelism
Wave 0 (serial, coordinator) : T0 setup, T1 baseline suite
Wave 1 (6 lanes parallel) : A Runtime · B AddressSpace · C ControlPlane · E Scripting · F Galaxy · H1 HistoryRead cap
Wave 2 (8 lanes parallel) : AbCip · AbLegacy · TwinCAT · FOCAS · Modbus · S7 · G1 OpcUaClient+MTConnect · G2 MQTT+Sql
Wave 3 (4 lanes parallel) : D AdminUI · H2 Historian remainder (+B2 last) · K Clients/CLI/Transport · E5 Alarm comment table
Wave 4 (serial) : T-INT full suite + fixture gates + docker-dev gates + hw userGates + docs/index + push-on-OK
The wave order follows file contention:
- Wave 2 driver lanes depend on A's dispose contract only for review context, not for code.
- D waits for the Modbus/FOCAS editor and form edits in Wave 2.
- B2 waits for B (
OtOpcUaNodeManager.cs). - H2 waits for H1 (
HistoryPaging.cs) and owns the recorder.
Merge order inside a wave is alphabetical by lane, rebasing as needed. Two lanes never share a function.
Wave 0
Task 0: Setup — persist the spec, file the issues, materialize the plan
Goal: The reports and plan live in the repo, there is one Gitea issue per lane, and native tasks exist.
Files: Create docs/reviews/2026-10-21/*.md (copy all 9 from the session scratchpad …/scratchpad/review/), docs/superpowers/plans/2026-10-22-review-remediation.md (this plan), and ….md.tasks.json.
Acceptance Criteria:
ls docs/reviews/2026-10-21 | wc -l= 9- One Gitea issue per lane (A, B, B2, C, D, E, E5, F, G1, G2, H1, H2, K, AbCip, AbLegacy, TwinCAT, FOCAS, Modbus, S7). Each issue carries its findings as
- [ ] R0x:IDlines, and the issue numbers are recorded in tasks.json. - Cross-repo issues are filed:
- mxaccessgw: lease never extended without
StreamEvents+ no renew RPC;mxgateway.sessions.opennot decremented on sweep close. - HistorianGateway:
max_eventsonReadEvents; a batchSendEventsRPC; historiangw#27 tracking reference.
- mxaccessgw: lease never extended without
- Commit
docs: add 2026-10-21 system review + remediation plan.
Verify: git show --stat HEAD lists the reports and the plan.
Model: sonnet (issue filing), coordinator commits.
Task 1: Baseline full suite
Goal: Record the pre-change green or flake baseline, so regressions are attributable. Acceptance Criteria:
dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Releasesucceeds.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "Category!=E2E&Category!=LiveIntegration"has its pass/fail/flaky list saved todocs/reviews/2026-10-21/baseline.txt.
Verify: tail -5 docs/reviews/2026-10-21/baseline.txt.
Model: coordinator.
Wave 1 — high-severity core (6 parallel lanes)
Lane A — Runtime driver lifecycle (fable; review fable)
Owns:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/**Runtime/Health/**Runtime/Telemetry/**src/Core/ZB.MOM.WW.OtOpcUa.Cluster/TelemetryOptions.cs+ the telemetry dialersrc/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cssrc/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs- the
IFleetDiagnosticsClientconsumer page (one new Razor file) +AdminUI/Clients/FleetDiagnosticsClient.cs
Tests: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests, tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests
- A1 Dispose drivers on final stop (
R02:L1,R02:P4,R06host context,R07:L2).- In
DriverInstanceActor.PostStop:ShutdownAsync(cts.Token).WaitAsync(5 s), with a logged timeout. ThenDisposeAsync/Dispose, but only when the actor is stopping for good. OverridePreRestartto skip disposal, because the Props closure reuses the driver. DiscoveryDriverBrowserdisposes its throwaway driver infinally.- Tests:
- A fake
IAsyncDisposabledriver is disposed exactly once onContext.Stop. - It is NOT disposed on a supervision restart (positive control: it still serves after restart).
- A hung
ShutdownAsyncdoesn't pin a thread beyond 5 s.
- A fake
- In
- A2 Single-flight reconnect (
R02:H2). Add an_initInFlightgate cleared by the pipedInitializeSucceeded/Failed, and bound the teardown. Test: with a 15 s init and a 10 s cadence,ShutdownAsyncandInitializeAsyncnever overlap (record interleavings in the fake). - A3 Writes off the mailbox (
R02:P2). RunWriteAttributeviaPipeTo, capturingSender, with a per-driver in-flight cap (default 4). Subscribe/unsubscribe stayReceiveAsync. Tests:- Two concurrent 3 s writes both complete under the host's 8 s Ask.
- Data changes are delivered during a pending write.
- A4 Housekeeping:
_lastReprovisionUtc.RemoveinStopChild(R02:L2).Stale()drop-receives (R02:H5).- Remove the unused
ILoggerFactoryctor slot (R02:U5). - Move the
OnAttributeValuePublishedseam to a test helper (R02:U4). DbHealthProbeActoroff-thread + 2 s command timeout (R02:P7).- Audit all
PollGroupEngine.Unsubscribesync callers and switch them toUnsubscribeAsync(R05:8). TelemetryLocalHub._evictedtimestamped sweep with TTL > max in-flight call (R02:L4).
- A5 Wire
TelemetryDial:CallTimeoutSecondsinto the dialer'sCallOptionsdeadline (R02:U2). Test: a stalled stream is cancelled at the configured deadline. - A6 Real
GetDiagnostics(R02:U1). Populate it from child health snapshots (real id, device counts fromGetHostStatuses, last-change time), and giveIFleetDiagnosticsClienta consumer: a per-node diagnostics panel on/fleet. That Razor file is owned here and live-verified in Wave 4.
Lane gate: Runtime.Tests green. The docker-dev gate (Wave 4) shows a driver config change disposing the old instance: the ObjectDisposed/dispose log appears, and process thread/handle counts stay flat across 20 respawns (dotnet-counters).
Lane B — OPC UA address space (fable; review fable)
Owns:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**, exceptHistoryPaging.cs/RawPageLimit.cs(H1) and the HistoryReadEvents projection (B2)Runtime/OpcUa/OpcUaPublishActor.cs
Tests: OpcUaServer.Tests, OpcUaServer.IntegrationTests
- B1 No phantom nodes (
R01:L1,L4,H2):WriteValueLockedis non-creating (unknown key ⇒ skipped, counted, Debug-logged).WriteAlarmConditionunknown id ⇒ logged no-op.- Delete the applier's equipment-id
RemovedConditionStatewrite. - Add a cross-map
(realm,id)collision guard inEnsureFolder/EnsureVariable/MaterialiseAlarmCondition. - Tests:
- A publish racing a surgical remove leaves no node.
- A re-add after that gets the authored DataType, parent and writability (positive control).
- B2a Stale MonitoredNode2 (
R01:L2).- On every removal path (rebuild,
RemoveVariableNode,RemoveAlarmConditionNode,RemoveEquipmentSubtree): queueBadNodeIdUnknownto the node's items,MonitoredNodes.Remove(nodeId), and drop_liveItems/_eventItems. - Revalidation restore re-resolves the node and does not reuse
Handle.Node. - Integration test (real SDK): subscribe, rebuild, create a new item on the same NodeId. The new item receives values (positive control) and the old one gets
BadNodeIdUnknown.
- On every removal path (rebuild,
- B3 Lock-time performance:
- Rebuild detaches top-level subtrees, not leaves (
R01:P1). - Per-equipment descendant index for
RemoveEquipmentSubtree(P2). LowerAllEventsMonitoringfor the surgical removal sites (P3).- Notifier wiring de-quadratic (
P5). - Benchmark test: rebuild a 10k-tag folder and remove 200 equipment from a 50k-node space. Assert completion < 1 s on CI hardware and record the before/after in the commit message.
- Rebuild detaches top-level subtrees, not leaves (
- B4 Write-path robustness:
OnEquipmentTagWriteobservest.Exceptionand wraps a synchronous throw (R01:L3,H5).- Shelve/condition timestamps use the injected
TimeProvider(H4). - SDK request quotas +
TransportQuotasconfigurable viaOpcUa:Quotas:*with the current defaults (P6). - HistoryRead arms: lower the default
MaxConcurrentHistoryReadBatcheson small nodes, and use anIAsyncNodeManageroverride if the SDK line exposesHistoryReadAsync; otherwise document it (P4).
- B5 Delete dead v3 equipment-tag materialisation (
R01:U2): the composer'sEquipmentTags, applierMaterialiseEquipmentTags, the surgicalChangedEquipmentTagsloop, and the classifierTagDeltaIsSurgicalEligible. KeepIsEmptysemantics; the full OpcUaServer suite stays green. - B6 Off-thread deploy parse/plan (
R02:P5).DeploymentArtifact.Open+ParseComposition+AddressSpacePlanner.Computerun viaPipeTo; only sink materialisation runs on the pinned thread.- Delete the bytes-less
LoadArtifactSQL branch. - Test: value batches keep flowing while a 50k-node plan computes.
- B7 Capability bits refresh (
R01:U3). Re-evaluate event-notifier/HistoryWrite bits whenHistorianDataSource/Updateris set after the first apply, so a late assignment no longer needs a full rebuild. - Documented, no change:
R01:U4(native Confirm/shelve, by design) andR01:L5(bounded). Note both in the issue.
Lane gate: both suites green. Wave 4 docker-dev gate: a ScadaBridge-style subscription survives a surgical add, and a re-created item after a rebuild receives data (Client.CLI subscribe).
Lane C — Control plane / host / security (fable for C1–C2, opus for C3–C5; review fable)
Owns: src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/**, src/Server/ZB.MOM.WW.OtOpcUa.Host/** (except Host/Engines/**, which belongs to E), src/Server/ZB.MOM.WW.OtOpcUa.Security/**, src/Core/ZB.MOM.WW.OtOpcUa.Configuration/** (except Validation/DraftValidator.cs, which belongs to E).
Tests: ControlPlane.Tests, Host.Tests, Security.Tests, Configuration.Tests (central SQL)
- C1 Deploy wedge + consistent snapshot (
R03:2.2,3.2,3.5):- In PreStart, a recovered deployment with an empty expected set is re-seeded and re-dispatched (or marked
Failed+ResetForNext). HandleDeadlinetakes the race branch only when_expectedAcks.Count > 0.- The
Dispatchingrow and the expected set are created together in the coordinator. - Validate + compose from ONE snapshot (extend
DraftSnapshotwith clusters/nodes/ApiKeys) inside aSNAPSHOTread transaction. - Project
ArtifactBlobaway in full-row reads. - Tests:
- Inject a
HandleDispatchthrow; the nextStartDeploymentsucceeds (positive control: a normal deploy seals). - An edit between validate and compose cannot ship unvalidated.
- Inject a
- In PreStart, a recovered deployment with an empty expected set is re-seeded and re-dispatched (or marked
- C2 Admin singleton mailbox (
R03:3.1). MoveTestDriverConnectto a per-request child actor (orTask.Run+PipeTo) and run deploy compose off-actor; only the in-flight check is serialized. Test: an alarm ack completes in < 1 s while a 60 s Test Connect is pending. - C3 OPC UA start failure is visible (
R03:2.1,1.4):- Add an
OpcUaEndpointHealthCheck(Ready + Active tags), Unhealthy while the SDK failed to start. - A driver-only node rethrows (process exits non-zero).
- The "SDK not started" fact feeds the ServiceLevel calculator, so the peer takes Primary. If the calculator lives in a Lane B-owned file (
OpcUaServer/**), C only exposes the fact (IOpcUaEndpointState) and files anF<N>follow-up for Wave 3 to consume it. StopAsyncstops the SDK;DisposeAsyncis idempotent.- Tests: port-in-use ⇒
/health/ready503, and the ServiceLevel published drops.
- Add an
- C4 Security:
ApiKeyOptionsValidator+ValidateOnStart(non-blank pepper, minimum length, whenever driver or admin) (R03:2.3).- Finish JWT (
R03:2.4, user decision):AddJwtBearerusingBuildValidationParameters();JwtOptionsValidateOnStartwhen/auth/tokenis mapped; the/api/*machine endpoints accept a Bearer token as well as API keys. - Wire
IAuditActorAccessorthroughAuthenticationStateProvider, so audit rows carry the circuit principal (R03:2.5). - Delete the unregistered
CookieAuthenticationStateProvider. - LDAP login exception logged at Warning (
2.7). - Admin ops return a fixed message + correlation id (
4.1).
- C5 Retention + throughput:
DeploymentRetentionActoron the admin singleton: keep the last N=50 sealed rows per cluster plus anything referenced in the last 30 days; nullArtifactBlobon older rows; configurableDeployments:Retention:*(R03:1.2).AuditWriterActorhandles duplicate keys per row (2601/2627 ⇒ detach and retry) (2.6).MeshGrpcCommandDialerevicts endpoints absent fromContactsLoaded(1.3).- Async PSK refresh with an atomic swap instead of invalidate-on-refresh (
3.4). - Pre-auth API-key DoS: an in-memory key-id set from the artifact or
IDataPlaneAuthoritySource, refreshed everyStoreCacheSeconds; an unknown id is rejected without SQL; plus a per-endpoint token bucket (3.3). - Document the
4.2fail-open window indocs/security.md.
Lane gate: suites green. Wave 4 docker-dev gate: kill central-1 mid-dispatch, then the next deploy succeeds. Port 4840 taken ⇒ /health/ready 503.
Lane E — Scripting runtime safety + scripted alarms (fable for E1/E4, opus for E2/E3; review fable)
Owns:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/**,Core.ScriptedAlarms/**,Core.AlarmHistorian/**Runtime/ScriptedAlarms/**,Runtime/VirtualTags/**Host/Engines/**src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/**Configuration/Validation/DraftValidator.cs
Tests: Core.Scripting.Tests, Core.ScriptedAlarms.Tests, Core.AlarmHistorian.Tests, Driver.Calculation.Tests, Runtime.Tests (VirtualTags/ScriptedAlarms folders)
- E1 Script quarantine (
R03:1.1,R05:2,R07:L1,R07:P5):- A shared
ScriptRunGuardin Core.Scripting, keyed per script instance. - It refuses to start a run while that script's previous orphan is still executing. The result is Bad
"previous evaluation still running"plus a counter. - It quarantines after 3 consecutive timeouts: stop evaluating, publish Bad, log Error, metric
otopcua_script_quarantined, cleared on redeploy or source change. - Pass a
CancellationTokeninto scripts. - Make the Calculation pump async, so pool starvation is no longer charged to scripts.
- Wired into the scripted-alarm engine,
RoslynVirtualTagEvaluator/VirtualTagHostActor, andCalculationEvaluator. - Tests: a
while(true)script on a 1 Hz trigger leaves ≤ 1 orphaned thread after 30 triggers (ThreadPool.ThreadCountdelta). Positive control: a healthy sibling keeps evaluating.
- A shared
- E2 Sandbox hardening (
R05:10,R05:11):- Deny
System.Threadingwith an allow-list (CancellationToken,Interlocked,Volatile). - Deny
System.Type.GetType/GetMethod*/InvokeMemberandSystem.Runtime.CompilerServices.RuntimeHelpers. - Add an allocation budget: sample
GC.GetAllocatedBytesForCurrentThreadaround each run and quarantine on breach (Scripting:MaxAllocBytesPerRun, default 64 MB). Document that a pre-emptive OOM still needs an out-of-process runner, and file a follow-up issue for that. - Tests: each forbidden form gets an
OTSCRIPTdiagnostic, and the Monaco editor parity test still matches.
- Deny
- E3 Scripted-alarm engine (
R05:1,3,6,7,9,12):- Clear
_valueCacheinLoadAsync; move its write below the_disposedguard. - Bounded
Disposedrain (5 s, cancelable token). ScriptedAlarmSourcecatch logs at Warning.LocalDbStoreAndForwardSinkcached dead-letter counter, resynced with the queue counter.- Script-log sinks rate-limited per source (20/min + summary).
- Clear
- E4 Alarm host restart + virtual-tag log storms (
R02:H1,H3,P6,L3;R07:P4,H6,H8,U5):ScriptedAlarmHostActorreceives aFunc<(upstream, engine)>factory, building its engine per incarnation.- Linked 10 s timeouts on
OnAlarmCommand. DependencyMuxTagUpstreamSource.RetainOnly(loaded deps).VirtualTagActorper-tag failure-log rate limit.- Calculation:
- negative-cache compile failures by source hash, with the Warning/script-log only on the Good→Bad transition;
- the mux retains the last
(value,status,ts)and replays it onRegisterInterest, with inputs recorded regardless of_running; - structural array dedup;
ClearCompiledScriptsfromShutdownAsync;- a DraftValidator rule
CalculationNoEffectiveTrigger.
- Tests:
- Throw inside a
DependencyValueBatchhandler, then a laterApplyScriptedAlarmsloads cleanly. - A calc Restart republishes the current inputs.
- Throw inside a
Lane gate: suites green. Wave 4 docker-dev gate: author a while(true) virtual tag. The node stays responsive, the tag shows quarantined, and the thread count stays flat over 2 min.
Lane F — Galaxy driver (fable; review fable)
Owns: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy*/**, the AdminUI GalaxyDriverForm.razor.
Tests: Driver.Galaxy.Tests, Driver.Galaxy.Browser.Tests
- F1 Real teardown (
R06:G-1,G-2,G-9,G-3):ShutdownAsyncstops the pump, alarm feed, deploy watcher, probe watcher and supervisor, disposes the session + clients, and nulls the seams soInitializeAsyncrebuilds.DisposeAsyncstays the idempotent final teardown.StartDeployWatcherAsyncstops the previous watcher.ReconnectSupervisordisposes each cycle's CTS.- Tests with fake gRPC clients:
- After
ShutdownAsync, every stream is cancelled and both channels are disposed. - Five watchdog cycles leave exactly one DeployWatcher (positive control: rediscovery still fires once per deploy).
- After
- F2 Probe watcher + liveness:
- Register probe handles in
SubscriptionRegistryunder a reserved id, andRebindthem inResubscribeAllAsync(G-4). GalaxyDiscovererreturns its fetched hierarchy, so discovery fetches once (G-7).- A throttled
LastSuccessfulReadstamp fromOnPumpDataChange(G-11). - An
EventPumpdispatch-loop exit ⇒NotifyStreamFault(G-10). - Test: a
ScanStateevent updatesGetHostStatusesand survives a reconnect.
- Register probe handles in
- F3 Wire knobs + writes:
DiscoverPageSize→DiscoverHierarchyAsync. If the mxaccessgw RPC lacks a page parameter, add a cross-repo task in mxaccessgw and page client-side meanwhile.ProbeTimeoutSeconds→ probe deadline (G-5,G-6).- First-touch write pipeline: concurrent AddItem/Advise across distinct tags, bounded at 8 (
G-8; acks stay serial by design).
Lane gate: suites green. Wave 4 userGate: with a reachable mxaccessgw, 20 config deploys keep mxgateway.sessions.open flat, read from the gw metrics endpoint.
Lane H1 — HistoryRead raw cap (fable; review fable + paging fuzz)
Owns: OpcUaServer/HistoryPaging.cs, RawPageLimit.cs, the ServerHistorian options type, and the ReadRawAsync methods of the three *HistorianDataSource.cs files.
Tests: OpcUaServer.Tests (paging), Historian.Conformance, the backend *.Tests
NumValuesPerNode = 0⇒ server page sizeServerHistorian:MaxRawValuesPerRead(default 10 000), with a continuation point via the existingRawPageLimitpaging. The cap is advertised asHistoryServerCapabilities/MaxReturnDataValues.- Backend
TOP/LIMIT/max_valuesare bounded by the same cap (R08:1.1). - A paged-vs-unpaged fuzz test: random windows/caps against a wire-faithful fake for each backend, where the concatenated pages equal the unpaged truth, with boundary ties.
Lane gate: the fuzz runs 10k iterations green. The Wave 4 SqlServer/QuestDb fixture gate reads a year window at N=0 and gets pages of ≤ 10k with a CP, and heap stays < 500 MB.
Wave 2 — driver lanes (8 parallel; opus implementer, opus review)
Every driver lane also:
- (a) makes
ShutdownAsynca complete teardown; - (b) applies the Global health-contract checklist;
- (c) build-and-swaps device tables;
- (d) adds
IBatchSubscribablewhere it runsPollGroupEngine(R02:P1).
Each lane owns src/Drivers/<Driver>*/** + its tests/Drivers/<Driver>*, its AdminUI tag-config editor/driver form files, and its Cli/<Driver>.Cli.
Lane AbCip
- Probe classification:
NotFound⇒ reachable, with a config warning (R07:H10). - Widen
IsReachableStatus, cache the runtime with its refusal status, map 0x1C/0x20/0xFF-0x2104 explicitly (H1,L4). - Native window ≥ 64 (
P8); cold waiters don't hold slots (P7addendum). - Bit-in-word parent width from
GetSize()(H17). - Write-side health no longer latches or overwrites (
H19). - BOOL-array element decodes bit n%32 of DWORD n/32 (
U11). - Array write encode (
U10). - UDT layout cache (
P11). - Device-table swap (
H13);volatile _health(H21). DisposeAsyncinstead of sync-over-async (F10).
Lane AbLegacy
- Per-device
ConnectionBackoff+ single-flight connect + bounded parallel devices + adjacent-element coalescing (R07:P7). NotSupportedException⇒BadNotSupported, no health, reject atFromTagConfig(H16).- Table swap (
H13); prune semaphores on index change (L7). - Array write encode (
U10);IBatchSubscribable. - Document the PLC-5 write capability gap with a hardware follow-up issue (
U6).
Lane TwinCAT
- Per-registration isolation with dead-registration retry (
R07:H11). - Stamp
LastSuccessfulReadfrom notifications + probe, and degrade on N consecutive router failures (H12). - Warn once per tag (
P10). - Parent-width bit RMW (
H18). - Array + string writes via the codecs (
U9). BadNodeIdUnknownfor unresolved native refs (U12).- Bounded probe join, drop counter, queue purge on dispose, re-init table swap (
L8);IBatchSubscribable.
Lane FOCAS
ConcurrentDictionary/swap_devices(R07:H13).- Widen macro/param numbers to int on the wire client (
H14). - Fixed-tree loop writes health (
H15). - Reject
Stringin the factory + editor (U8). - Concurrent per-device reads (
P9). - Recycle under
ConnectGate(L6). - Alarm-layout heuristic uses the record count from the header (
H20).
Lane Modbus
- Deadband baselines on
(value,status)(R07:H2). - Transport self-heals with a throttled in-gate connect (
P3). - Dispose the failed
TcpClient, one in-gate attempt, DNS under the timeout (L3). - Wire Family/MelsecSubFamily/DisableFC23 (
U1): the editor + factory route the address throughModbusAddressParserand persist the resolved region/offset;DisableFC23gates multi-write. - Coalescing on by default (gap 8), plus coils/DI/BitInRegister folding (
U2). - String[]/BitInRegister[] writes (
U3). - Region/type validation at the factory (
P6). - Exception codes 01/02/03 are refusals (
H4). - Write status mapping (
H7); PDU length check (H9).
Lane S7
- Dead-handle short-circuit inside a pass (
R07:P1). - Per-member span fallback + "do not group" memo (
P2). - Per-tag skip +
TryParseinFromTagConfig/Inspect, so the deploy gate refuses bad tags (H3). - Non-fatal
ReadData/WriteDataare refusals (H4). _lifecycleGate(Modbus pattern) (H5).- Bool-array bit-offset reject at authoring, or bit-packed decode (
U4). - Probe gate contention ≠ Stopped; honour
TimeoutMs(H7).
Lane G1 — OpcUaClient + MTConnect
OpcUaClient:
- Data-subscribe cleanup on throw (
R06:O-1). - Ack rethrows the transport status (
O-8). - Split the read/write gate from subscription management; no gate across the recursive browse (
O-6). - Close the local session on an init throw (
O-2). - One teardown budget (
O-9). - Wire
ProbeTimeoutSeconds(O-4). - Browse
AttributesAsyncreal + certificate-auth browse (O-5).
MTConnect:
- Generation guard on cursor publish (
T-1). - Read through the live index when the pump is healthy (
T-4). - Lag metric + adaptive
count(T-5). - Instance-id check in
ReadAsync(T-2). - Delete dead parsed fields (
T-3). - Batched PUT (
T-6). - Headers-only probe (
T-7). - Redacted URI everywhere (
T-8).
Lane G2 — MQTT + Sql
MQTT:
- Failed SUBSCRIBE ⇒ pending set retried on reconnect/reconcile,
SubscribeAsyncthrows on a transport failure (R06:M-1). - Wire
birthObservationWindowSecondsintoMqttDriverBrowser.OpenBudget+ discovery UntilStable (M-4). - Array leaves not bindable, plus a Sparkplug-array-read feature issue (
M-5). - Prune
LastValueCacheon Register (M-2). - Dispose the failed connection (
M-3). - Wildcard topic trie (
M-6); static UTF-8 encoder (M-7). - Bounded concurrent publishes (
M-8); guards (M-10,M-11).
Sql:
- Row cap with a
TOP/LIMIT N+1dialect + config-error status (S-1). - Rate-limited warnings (
S-6); plan cache (S-7). - Dispose
_gate(S-3). Querymodel refused at the deploy gate via the parser + feature issues for MySql/Odbc/allowWrites (S-4).IS_NULLABLEin browse attributes (S-5).- Withhold JSON parser text (
S-8);IBatchSubscribable.
Driver lane gates:
- Wave 4 fixture runs on
10.100.0.35: modbusstandard, s7s7_1500, abcipcontrollogix, opcuaclient opc-plc, mqtt + sparkplug, sql. Each lane adds or extends an IntegrationTests case per High finding. - Hardware userGates for real controllers: AbCip/AbLegacy/TwinCAT array writes, FOCAS macro > 32767, AbCip probe-typo recovery.
Wave 3
Lane D — AdminUI (opus for D1–D2, sonnet for D3–D4; review opus)
Owns: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/**, except the per-driver editors/forms owned in Wave 2 and the A6 diagnostics panel.
- D1 Monaco sync correctness (
R04:P1,P9,S2,S3):- Fetch the editor text on demand at Save/blur via
MonacoBlazor.getValueinstead of per keystroke. HubOptions.MaximumReceiveMessageSize= 1 MB.- Diagnostics generation counter +
AbortController. - Drop the inlay-hints provider/endpoint.
- Build a Problems list using
MarkersChanged+RevealLineAsync.
- Fetch the editor text on demand at Save/blur via
- D2 Roslyn analysis (
P2,P3,S1):- One compilation per scoped service with
ReplaceSyntaxTree. RequestAbortedplumbing + Monaco token honouring.- Sorted
string[]+ dictionary in the catalog snapshot. - An
OTSCRIPT_SETVTAG_INERTwarning marker.
- One compilation per scoped service with
- D3 Rendering (
P4–P8,H2):/hostsdirty flag on tick.RawTreecached menus +Virtualize+ 500-per-level paging.- CSV import grid
Virtualize+ export via a download endpoint. - ScriptLog/Alerts 200 ms coalescing + ring buffer.
/scriptsprojection.- Cached modal parameter dictionaries.
- D4 Leaks/hazards/stubs (
L1–L5,H1,H3,S4–S7):- Snapshot-store eviction against the config load.
Threading.TimerinDriverTestConnectButton.AlarmsHistorianpageFleet.razorpattern + "no historian on role" state.- Close an orphaned browse token after dispose.
- Per-subscriber try/catch in broadcasters.
- Confirm dialogs on equipment-page and ScriptEdit deletes.
- Document the theme (
H3). - Delete dead
UnsTreeServicemembers. GET /api/deployments/{id}.- Equipment CSV via
CsvParser. - Gate
/dev/*behind#if DEBUG.
Lane gate (userGate-free, coordinator-run): docker-dev Chrome pass.
- Paste a 100 KB script, Save, reload: the source persists.
- Two-tab
/hostswith 50 drivers: CPU stays flat. - Expand a 5k-tag device: renders in < 2 s.
- Each delete confirms.
- The A6 diagnostics panel shows real counts.
Lane H2 — Historian remainder (opus; review opus)
Owns: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.*/** (minus H1's files), Runtime/Historian/** (recorder + adapter), docs/Historian.md, the AdminUI historian backend picker.
- Gateway events: drain to completion with a budget, cancellation arms (
R08:1.2,2.5). - Drop native
TimeAverage/TotalbehindServerHistorian:Gateway:EnableNativeTimeWeighted=falseuntil historiangw#27 (2.1). - Capability matrix in docs + picker + a startup warning for continuous historization on Gateway (
2.2). ProbeAsynchealth timer (2.4).- Writers:
- OCE rethrow in all three (
4.1). - Gateway via
LiveWritePreparer(4.2). IHistorianValueWriter.FlushAsyncpass-end hook, QuestDB WAL wait once per pass with exponential poll (3.1).
- OCE rethrow in all three (
- Batched alarm-history writers: SqlServer TVP +
OUTPUT; QuestDb multi-row; Gateway bounded pipelining until a batch RPC (3.2). - Retention pre-filter (
3.3). - QuestDB symbol indexes + migration (
3.4). - Presence cache exact lookup (
3.5). - Paged
DeleteRaw(4.3). - QuestDB trail
appliedmarker + DEDUP key (4.4). - Recorder:
CommitMode=Periodic(100 ms)default + off-mailbox append with an in-flight cap + mailbox-depth metric (R02:P3); timeouts onGetStatus(R02:H3). HistorianAdapterActorbounded channel + delete the senderless receive (R02:H4,U3).- Outbox scan outside the lock (
R08:1.3).
Lane gate: Historian.Conformance green on all backends. The Wave 4 AVEVA live gate (userGate, existing recipe in .claude/rules/historian.md) checks events paging and that TimeAverage is refused.
Lane K — Clients, CLI, transport (sonnet; review opus)
Owns: src/Client/**, src/Drivers/Cli/Driver.Cli.Common + the AbCip/AbLegacy CLI command files (after the Wave 2 merge), Driver.Ab.Transport/**.
- Client:
- release the raw CP (
R08:1.4); RemoveSubscription(1.5);- align
AggregateTypewithAggregateFamilies.Served(2.3); - lazy HasChildren (
3.6); - NodeId dictionary in the UI (
3.6); - failover under
_subscriptionLock, andConnectAsyncrefusing when already connected (4.5).
- release the raw CP (
- CLI fixes (
R07:U7). - Pooled buffers in
EncapsulationClient/CipWriter/LogixTagClient+ Modbus transports viaArrayPool(R07:L5). Benchmark-gated: no regression inAb.Transport.Teststimings.
Lane B2 — HistoryReadEvents full projection — folded into Lane H2 as its LAST task (opus; review fable)
This runs inside H2 because it shares the backends' event read paths with H2's Gateway events-drain fix. It runs after H2's other tasks, and OtOpcUaNodeManager.cs is free by then because B merged in Wave 1.
Adds to H2's ownership: the HistoricalEvent contract in Core.Abstractions and the HistoryReadEvents projection in OtOpcUaNodeManager.cs.
- Carry
EventType/ConditionName, mapSourceNodewhen resolvable, and honour anOfTypewhere-clause (R01:U1). - Test over the wire: a Client.CLI
historyread --eventswith a select clause including EventType returns non-null values.
Lane E5 — Alarm comment audit table (opus; review fable)
Owns: Core.ScriptedAlarms/Part9StateMachine.cs, the alarm state store + its LocalDb schema/migration.
- Move the append-only comments to their own table keyed by alarm id, keeping replication to the pair peer. Each condition row stays a fixed size.
- Migrate existing inline comments idempotently (
R05:4). - Test: 10k comments, then a transition's
SaveAsyncpayload size is constant. A pair-replication test shows the comments on the peer.
Wave 4 — integration (serial, coordinator)
Task INT-1: Full suite + flake triage
Run the full suite again. Any new failure versus baseline.txt is either fixed or filed. Loop any suspect test under yes contention.
Task INT-2: Fixture gates (sims on 10.100.0.35)
For each driver lane, sync and bring up its fixture, then run *.IntegrationTests with the env endpoints. Record the results in docs/reviews/2026-10-21/gates.md.
Task INT-3: docker-dev live gates (coordinator, Chrome automation + Client.CLI)
This runs the lane gates for A, B, C, D and E listed above, plus the A6 /fleet panel and the H1 N=0 paging read through Client.CLI historyread.
Task INT-4 (userGate): hardware and AVEVA gates
USER-ORDERED GATE — NON-SKIPPABLE. This task was requested by the user in the current conversation (array encode on real controllers). It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in
acceptanceCriteriahas been re-validated independently, with output captured.
Acceptance criteria:
- An AbCip array write round-trips on a real ControlLogix.
- An AbLegacy N-file array write round-trips on a real SLC/MicroLogix.
- A TwinCAT array + STRING write round-trips on real hardware.
- A FOCAS
MACRO:> 32767 reads the correct variable. - Galaxy: 20 deploys keep
mxgateway.sessions.openflat. - AVEVA: the events paging +
TimeAveragerefusal gate.
requiresUserSpecification: true for the bench endpoints.
Task INT-5: Docs, rules, index, push-on-OK
- Apply each lane's suggested
.claude/rules/*.mdtext in the main checkout. - Update
CLAUDE.mdwhere behaviour summaries changed: the health contract, surgical/rebuild notes, the historian N=0 rule. - Update
../scadaproj/.claude/skills/sister-project-history/otopcua.md(+ the index row only if the summary changes) on scadaprojmain, following the CLAUDE.md recipe, and push scadaproj. - Draft the per-issue closing comments, then ask the user for OK before pushing OtOpcUa master and closing the issues.
- Save project memory for lessons learned.
Verification (end to end)
dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release+ the full unit suite match the baseline or better.- Fixture IntegrationTests green per driver (INT-2).
- docker-dev gates (INT-3):
- Respawn churn leaves threads/handles flat (
dotnet-counters monitor -p <pid> System.Runtime). - A re-subscribe after a rebuild receives data.
- The deploy wedge recovers.
- A spin script is quarantined.
- A 100 KB script saves.
- An N=0 HistoryRead pages.
- Respawn churn leaves threads/handles flat (
- Hardware/AVEVA userGates (INT-4).
- Each Gitea issue checklist has every
R0x:IDticked with a commit SHA, or explicitly re-filed as a follow-up.
Self-review notes
- Coverage: every finding ID in reports 01–08 maps to a lane above. The two documented-no-change items (
R01:U4,R01:L5) are recorded in the B issue, andR08:2.2is gateway-side and handled through cross-repo issues + docs. - Conflict check: the files shared across lanes are
OtOpcUaNodeManager.cs(B in Wave 1, then H2's last task in Wave 3),HistoryPaging.cs(H1 only), the three*HistorianDataSource.cs(H1'sReadRawAsyncin Wave 1, H2 for the rest in Wave 3), the recorder (H2 only),DraftValidator.cs(E only), and AdminUI driver forms (driver lanes in Wave 2, then D in Wave 3). No two lanes in the same wave share a file. - Concurrency budget: up to 8 worktree builds in Wave 2. If the machine thrashes, split Wave 2 into 2×4: AbCip/AbLegacy/TwinCAT/FOCAS, then Modbus/S7/G1/G2.