Files
lmxopcua/docs/reviews/2026-10-21/gates.md
T
2026-11-12 22:59:38 -05:00

49 KiB
Raw Blame History

2026-10-21 review remediation — Wave-4 gate record

Tracking issue: lmxopcua#761. This file records each gate run with its date, the commit tested, the procedure, the measured numbers and the verdict. Gates not listed here have not been run.

docker-dev gates A, B, C, E — 2026-11-09

Commit tested 1b9c36f3 (origin/master, "docs(plans): OPC UA SDK 2.0 upgrade spike catalogue (lmxopcua#782)")
Rig local docker-dev/ (6 host nodes: MAIN central-1/2, SITE-A site-a-1/2, SITE-B site-b-1/2), image otopcua-host:dev sha256:24f9d7287ed2 built from that commit, migrator re-run (no pending migrations)
Clock Times below are rig UTC (2026-11-10 01:37–02:07 UTC = 2026-11-09 evening US Eastern)
Config The existing ConfigDb: MAIN carries MAIN-modbus (pymodbus 10.100.0.35:5020), MAIN-opcua (opc-plc 10.100.0.35:50000), MAIN-calc, 48 int3b-mb-* Modbus drivers and one int3b-blackhole driver, 52 children in total. Equipment EQ-32296cc68964 (a/l/e1) has the HR5 reference and the change-triggered virtual tag okvt (= HR100 + 1).
Tools POST /api/deployments + GET /api/deployments/{id} (X-Api-Key: docker-dev-deploy-key), Client.CLI built from the same commit, /proc/1/status (Threads) and /proc/1/fd inside the containers. dotnet-counters is not in the runtime image, so it was not used. ConfigDb edits were made with sqlcmd -I (QUOTED_IDENTIFIER on).

Summary: A PASS, B PASS, C PASS, E PASS. None of the four gates failed. Observations are listed at the end. None of them blocks a gate.

Gate A (#731) — a driver config change disposes the old instance; threads and handles stay flat — PASS

Procedure. Each iteration edits both MAIN-modbus.DriverConfig (TimeoutMs 2000+i) and MAIN-opcua.DriverConfig (SessionName gateA-i), deploys, waits for the deploy to seal plus 8 s, then samples PID 1 in central-1 and central-2 (both run the MAIN drivers). That is 20 iterations, so 20 respawns of each driver on each node: 80 driver disposals in total. Two warm-up respawns ran before the baseline.

Disposal evidence (log lines in the 01:41:47–01:45:10 window):

node MAIN-modbus: disposed Modbus driver on stop MAIN-opcua: disposed OpcUaClient driver on stop spawned … MAIN-modbus / MAIN-opcua shutdown/dispose timeout or throw
central-1 20 20 20 / 20 0
central-2 20 20 20 / 20 0

Counts (threads / open fds / RSS KiB):

sample central-1 central-2
baseline (01:41:47) 46 / 714 / 490 748 46 / 717 / 516 600
after respawn 5 50 / 725 / 506 576 51 / 725 / 529 472
after respawn 10 50 / 725 / 507 084 48 / 726 / 532 972
after respawn 15 48 / 726 / 497 648 48 / 724 / 525 440
after respawn 20 (01:44:59) 52 / 726 / 499 048 48 / 723 / 526 184
settled, 75 s later (01:46:14) 48 / 712 46 / 715

Across the 20 respawns, threads moved between 47 and 55 with no upward trend; that range is normal thread-pool movement. The fd count rose by about 11 on the first respawn, which is the new driver's sockets landing before the old ones close. It then stayed flat at 723–726, and after settling it was lower than the baseline (712/715). RSS stayed flat (±25 MB).

After a respawn, the static Modbus values (HR5 and HR200, which do not change) read Good on both 4840 and 4841. The dead child's BadNoCommunication fan, which lands after the new child's first push, was overwritten by the re-push.

Gate B (#732) — a subscription survives a surgical add; a re-created item after a rebuild receives data — PASS

Procedure. Session 1 opens two Client.CLI subscribe processes (500 ms) on opc.tcp://localhost:4840, which are held for 170 s:

  • the raw node ns=2;s=pymodbus/plc/HR100 (ticks about 1 Hz)
  • the UNS virtual tag ns=3;s=EQ-32296cc68964/okvt
  1. Surgical add (01:47:42). Insert the raw tag HR101 on MAIN-modbus-dev, then deploy. The applier logged kind=PureAdd, added=1, removed=0, changed=0, rebuild=False on both central nodes.
  2. Read the new node. ns=2;s=pymodbus/plc/HR101 exists and is served. Its status is BadOutOfRange because the pymodbus fixture refuses address 101 with exception 0x02. The device answered, so this is the fixture's register map and not a server fault.
  3. Rebuild (01:48:19). Rename equipment e1 to e1r, which is a ChangedEquipment delta, then deploy. The applier logged kind=Rebuild, … rebuild=True.
  4. Re-create (01:48:35). With session 1 still open and still holding its stale items, start session 2 with new subscriptions on the same two NodeIds for 20 s.

Results.

subscription samples non-Good samples
session 1, HR100 59 01:47:43.1426 BadNoCommunication (one sample; Good 31505 0.9 ms later, then 1 Hz Good through 01:48:19); 01:48:19.97 BadNodeIdUnknown, then nothing (item dead, as B2a specifies)
session 1, okvt 59 same pattern: one BadNoCommunication sample at 01:47:43.1426, Good again at .1435; BadNodeIdUnknown at 01:48:19.97
session 2, HR100 (after the rebuild) 20 0. Values ran from 36580 at 01:48:34.99 to 38405 at 01:48:54.00
session 2, okvt (after the rebuild) 20 0. Values ran from 36581 to 38406
  • Surgical add: the subscription survived. The items were never invalidated and Good data continued at 1 Hz. The single BadNoCommunication sample is not address-space churn. Adding a tag changes the driver's spawn spec, so MAIN-modbus respawned (stopped driver child / disposed / spawned at 01:47:43). The dead child's connectivity-lost fan then reached the item for under 1 ms before the new child's push (see observation 3).
  • Rebuild: the old items received BadNodeIdUnknown and no further data. New items on the same NodeIds, created while the stale items were still open in another session, received live data immediately. That is the B2a positive control: before the fix, a stale MonitoredNode2 captured the new item.

The CLI cannot re-create an item inside the same session. The same-session case is covered by the OpcUaServer.IntegrationTests test, not by this gate.

Gate C (#733) — kill a central mid-dispatch, the next deploy succeeds; port 4840 taken ⇒ /health/ready 503 — PASS

To hold a deployment in flight long enough to kill a node, site-b-1 was docker paused before each deploy, so its ack could not land. The dispatch is otherwise sub-second on this rig. Both centrals were killed in turn: central-1, which the issue names, and central-2, which was hosting the config-publish / admin-operations singletons at the time. Killing the singleton host is what exercises the C1 PreStart recovery.

C-a: docker kill central-1 mid-dispatch. Deployment 197e31b4 was AwaitingApplyAcks with 5 of 6 rows Applied (site-b-1 Applying) when central-1 was killed at 01:51:42.

  • A second POST while it was in flight returned 409 AnotherDeploymentInFlight, which is correct.
  • After site-b-1 was unpaused, the coordinator closed the deployment at the 2 min deadline (01:53:38) as TimedOut. It logged No ack from: site-b-1:4053, because the gRPC dispatch to the paused node was lost.
  • central-1 was restarted, and the next deploy, b27ede2a, sealed with 6 of 6 Applied.

C-b: docker kill central-2 (singleton host) mid-dispatch. Deployment 970e7770 was AwaitingApplyAcks (5 of 6) when central-2 was killed at 01:54:03.

  • 01:54:30: central-1 auto-downed central-2.
  • 01:54:46: the singletons started on central-1 and logged Coordinator recovered in-flight deployment 970e7770… (5/6 acks landed).
  • The AdminUI was unavailable through Traefik from about 01:54:05 to 01:54:35 during the hand-over.
  • The recovered deployment closed TimedOut at its original deadline (01:56:00).
  • central-2 was restarted, and the next deploy, cb0b8134, sealed with 6 of 6 Applied. Neither run left a deployment wedged in flight.

C-port: port 4840 taken.

  • Fused admin+driver node. central-2 was stopped. A replacement container was started from the same compose service (compose run --use-aliases). A perl listener in it bound 0.0.0.0:4840 before dotnet OtOpcUa.Host.dll started. Results:
    • GET /health/ready returned HTTP 503, overall status Unhealthy, with opcua-endpoint: Unhealthy — "OPC UA SDK failed to start: Failed to establish tcp listener sockets for Ipv4 and IPv6." (status Failed). configdb and akka were both Healthy.
    • The node logged OpcUaEndpointStepDown: … not the driver Primary — nothing to step down from.
  • Driver-only node. The same setup on site-a-2 (driver-only): the host logged FTL OtOpcUa.Host terminated: Failed to establish tcp listener sockets… and the process exited with code 1 (C3 "a driver-only node rethrows").
  • Both replacement containers were removed and the original containers restarted. The ServiceLevel half of C3 is #762 and was not part of this gate.

Gate E (#734) — a while(true) virtual tag leaves the node responsive, the tag shows quarantined, threads stay flat over 2 min — PASS

Procedure.

  • Script: gateE-spin with source var v = ctx.GetTag("pymodbus/plc/HR100").Value; while (true) { }.
  • Virtual tag: spinvt on EQ-32296cc68964, Int32, change-triggered, so it fires about 1 Hz on HR100.
  • Healthy sibling: okvt, on the same equipment and the same trigger.
  • Deployed at 02:01:11. Before the deploy and every 6 s for about 3.5 min after it, the following were sampled:
    • threads and CPU ticks of PID 1 on both centrals
    • /health/ready latency
    • Client.CLI read of okvt and spinvt on 4840

An earlier attempt used a timer-only trigger (TimerIntervalMs=1000, no dependency), and the script never ran; see observation 4.

Quarantine.

  • The first run started at about 02:01:16. The following triggers were refused, as designed: refusals do not count as strikes.
  • At 02:01:36, after 20.2 s, which is past StuckOrphanQuarantineFactor 10 × the 2 s budget, both centrals logged this Error to scripts-*.log: Script gateE-vt-spin quarantined: its previous evaluation has been running for 20.2 s, over 10x its 2000 ms budget … not evaluated again until its source changes or it is redeployed.
  • /metrics:
    • otopcua_script_quarantined_total{reason="stuck",script="gateE-vt-spin"} 1
    • otopcua_script_run_refused_total{script="gateE-vt-spin"} 21
    • Both stayed at 1 and 21 for the rest of the run, so evaluation stopped after the quarantine.
  • OPC UA status of ns=3;s=EQ-32296cc68964/spinvt: BadWaitingForInitialData until the first run, then BadInternalError (0x80020000) from 02:01:23 onward.

Responsiveness.

metric before the deploy over the 3.5 min after
/health/ready 200 in 3–20 ms 200 in 3–11 ms
Client.CLI read (connect + read) 0.34–0.46 s 0.33–0.49 s

okvt, the healthy sibling, kept evaluating Good throughout (30656 → 41368).

Threads (central-1 / central-2):

sample central-1 central-2
before 50–53 49–50
02:01:17 – 02:04:42 (3.5 min, sampled every 6–9 s) 50–54 50–54

There was no growth, and at most one thread was orphaned per node.

CPU. The orphaned run cannot be pre-empted in-process. Both nodes went from about 0.1 core to about 1.04 cores (about 104 ticks/s), and that continued after the virtual tag was deleted and redeployed (1022 ticks per 10 s at 02:05). Only a process restart cleared it, so both centrals were rolling-restarted after the gate. This is the documented limitation: the out-of-process runner is #750. The gate criteria (responsive, quarantined, threads flat) hold.

Observations (none blocks a gate)

  1. The status API does not say why a deployment timed out. In the normal timeout branch (some acks landed), GET /api/deployments/{id} returns status: TimedOut with failureReason: null. Only the central log names the silent node (No ack from: site-b-1:4053). The no-expected-rows branch does set a reason. An automation client polling the API cannot see which node to fix. Resolved (lmxopcua#784): the timeout branch now persists failureReason = Timed out waiting for acks (n/m landed). No ack from: <nodes>. … (at most 20 nodes named, the rest counted).
  2. Rig: mesh PSK warnings on the centrals. central logs Mesh PSK secret ot-mesh-psk-{main,site-a,site-b} … is absent (or blank) in the store; the key is REVOKED — commands to that cluster and acks from it are refused. Yet every gRPC dispatch and ack in these runs succeeded. Either the rig's PSK secrets are not seeded and enforcement is off, or the warning is stale. Not investigated. Resolved (lmxopcua#784): the warning was false, not a bypass. The rig's centrals supply all three keys through MeshTransport__ClusterPsks__{MAIN,SITE-A,SITE-B} (each equal to that cluster's nodes' MeshTransport__GrpcPsk) and seed nothing in the store. MeshPskProvider.TryGetPsk consults that override first on every lookup, so enforcement was on and every call authenticated against the override key; only the background RefreshAsync read the store for those clusters anyway and reported them revoked. The refresh now skips override-covered clusters, so REVOKED is logged only for a cluster with no key at all.
  3. A surgical raw-tag add respawns that driver. The driver's spawn spec carries its tag set, so adding one tag reconnects the whole driver (the accepted cost noted in DriverSpawnPlanner). On a live subscriber this shows as one BadNoCommunication sample (< 1 ms) on every value of that driver, including UNS references and virtual tags fed by it. ScadaBridge-style clients that alarm on a single Bad sample would see it on every tag add.
  4. A timer-only virtual tag never evaluates, and nothing flags it. VirtualTag.TimerIntervalMs is not consumed by the runtime: VirtualTagActor evaluates on dependency changes only, and no timer scheduler exists. A virtual tag with TimerIntervalMs=1000 and no ctx.GetTag dependency deployed cleanly and sat at BadWaitingForInitialData indefinitely. The CalculationNoEffectiveTrigger DraftValidator rule (E4) covers Calculation tags only, not virtual tags.
  5. The quarantine line in the main log has no script name. The guard's Error is mirrored into the main log by ScriptLogCompanionSink as WRN [Script] unknown emitted Error: Script "gateE-vt-spin" quarantined: …. The guard's logger carries no ScriptName/ScriptId property, so the mirror labels it unknown, and it is rate-limited under that shared key. scripts-*.log has the correct [ERR] line. Resolved (lmxopcua#784): the guard binds the script key as ScriptName, so the mirror names the script and rate-limits each one separately.
  6. The quarantine shows as BadInternalError on OPC UA. A client sees no quarantine-specific status. The reason is only in the logs and metrics. Decided (lmxopcua#784): kept BadInternalError, the documented code for any script-attributable fault on all three seams; BadOutOfService would read as a deliberately disabled point and would be a ScadaBridge-visible contract change. Rationale: docs/VirtualTags.md §"Quarantine: what a client sees".

Rig state left behind

  • All six nodes run otopcua-host:dev sha256:24f9d7287ed2 (commit 1b9c36f3).
  • Every gate edit was reverted in the ConfigDb: HR101 deleted; e1 name restored; gateE-spin/spinvt deleted; both driver configs restored. A clean-up deploy, cd41a890, sealed 6 of 6.
  • Both centrals were restarted afterwards to clear the gate-E orphan thread.

Driver fixture gates + H1 (2026-11-09, commit 1b9c36f3, fixture host 10.100.0.35)

Fixture gates, lmxopcua#761 (driver fixtures + H1)

Date: 2026-11-09. Commit tested: 1b9c36f3 (origin/master), worktree scratchpad/wt/otopc-fix (branch w18/fixtures, nothing committed). Release builds, 0 errors. Nothing posted to Gitea.

Fixture host 10.100.0.35: lmxopcua-fix does not exist on macOS (CLAUDE.md says to use ssh). Every needed fixture was already running, so nothing was started or stopped there. State before and after was identical (modbus-standard, ab-server-controllogix, python-snap7-s7_1500, opc-plc, mosquitto + sparkplug-sim + mqtt-publisher, sql-pg/oracle/blackhole, otopcua-mssql). Pre-existing: otopcua-opc-plc reports "unhealthy" (before and after; the opcuaclient suite still passed). otopcua-sql-blackhole showed "unhealthy" briefly right after the blackhole pause/unpause test, then "healthy" again.

Driver fixture gates

Gate Command (from worktree) Result
modbus standard dotnet test tests/Drivers/*Modbus.IntegrationTests -c Release (default endpoint 10.100.0.35:5020, profile standard) PASS: 12 passed, 0 failed, 32 skipped. Skips are the other profiles only (exception_injection 11, mitsubishi 7, dl205 ~11, s7_1500 4). They are not valid on the standard profile. Standard round-trip and AddressingGrammar ran.
s7 s7_1500 S7_SIM_ALLOW_WRITES=1 dotnet test tests/Drivers/*S7.IntegrationTests PASS: 23 passed, 0 failed, 3 skipped. Without the writes env var: 20 passed and 6 skipped. The 3 remaining skips: TM/CT address limit of python-snap7, and two destructive tests (S7_RECONNECT_BOUNCE_CMD, S7_TIMEOUT_OUTAGE_*_CMD) not run so the shared sim is not bounced.
abcip controllogix AB_SERVER_ALLOW_WRITES=1 AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/*AbCip.IntegrationTests PASS with one flake: 33 passed, 1 failed, 22 skipped (about 2 min). Failure: PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag raised DifferentialAbortedException ("Connection reset by peer" on tag UdtSeg.Ready). Rerun of that class: 2/2 passed. Verdict: flake (ab_server connection reset, aborts on first connection-level failure by design). Skips: 6 need AB_SERVER_PROFILE=emulate (Logix Emulate), 1 needs AB_HW_WRITE_GATEWAY (hardware), 15 are the remaining differential and write tests. Without writes env: 28 passed, 0 failed, 28 skipped.
opcuaclient opc-plc dotnet test tests/Drivers/*OpcUaClient.IntegrationTests (opc.tcp://10.100.0.35:50000) PASS: 5 passed, 0 skipped.
mqtt + sparkplug MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883 MQTT_FIXTURE_PLAIN_ENDPOINT=10.100.0.35:1883 MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<from publisher container env> MQTT_FIXTURE_CA_CERT=<scp'd ca.crt> dotnet test tests/Drivers/*Mqtt.IntegrationTests PASS: 30 passed, 0 skipped (includes Sparkplug, simulator running).
sql SQL_TEST_ENDPOINT=10.100.0.35,14330 SQL_TEST_PASSWORD=.. SQL_PG_ENDPOINT=10.100.0.35:15432 SQL_PG_PASSWORD=.. SQL_ORACLE_ENDPOINT=10.100.0.35:11521 SQL_ORACLE_PASSWORD=.. SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 SQL_BLACKHOLE_PASSWORD=.. SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 dotnet test tests/Drivers/*Sql.IntegrationTests PASS: 37 passed, 0 failed, 17 skipped. SQL Server, Postgres, Oracle and the blackhole timeout test ran. Skips: 14 MySQL (no MySQL fixture on the host) and 3 ODBC (no SQL_ODBC_CONNECTION_STRING).

Side effect: the SQL suite creates (and never drops, by design) database SqlPollFixture on otopcua-mssql. It was already there before (listed in sys.databases).

H1 (#736) historian year-window gate

There was no pre-existing year-window fixture test. The existing fixture suites test the cap at 3 rows, and the 10k fuzz is offline in OpcUaServer.Tests. I added an uncommitted probe, tests/Drivers/Historian.Conformance/YearWindowGateProbe.cs, with a subclass in each backend's integration project (edits are only in the throwaway worktree). It seeds one tag with 52,560 samples spanning 365 days, with maxRawValuesPerRead = 10,000.

  • It checks that an N=0 read of the whole year is refused with HistoryReadLimitExceededException (Limit=10000), not returned unbounded and not truncated.
  • It walks the window in 10k-capped pages, resuming after the last timestamp, and checks every page is at most 10000.
  • It checks the concatenated pages equal the seeded set (no loss, no duplicates, ordered), and that peak process working set stays under 500 MB.
Backend Where Result
QuestDb local throwaway container h1-gate-questdb (questdb/questdb:10.0.1, ports 28812/29000; removed afterwards) PASS: N=0 refused, limit 10000; 6 pages, max page 10000, 52,560 rows walked, no loss or duplicates; peak working set 122 MB (managed 27 MB). Full QuestDb integration project: 234 passed, 0 failed, 1 skipped (opt-in HISTORIAN_QUESTDB_STRESS_SECONDS stress test).
SqlServer throwaway DB OtOpcUaHistorian_T_* on otopcua-mssql (the fixture creates and drops it; no orphan left) PASS: same numbers. 6 pages, max 10000, 52,560 rows walked; peak working set 114 MB (managed 18 MB). Full SqlServer integration project: 196 passed, 0 failed, 0 skipped.
Server half: continuation point at N=0 offline: dotnet test tests/Server/*OpcUaServer.Tests --filter RawPageLimit/HistoryRead/MaxReturnDataValues/HistoryServerCapabilities PASS: 431 passed, 0 failed (includes the paged-vs-unpaged raw cap fuzz and the MaxReturnDataValues advertisement).

Scope caveat: the probe drives the backend IHistorianDataSource directly, not a live OPC UA HistoryRead through the server node manager. The backend side is proven live: bounded at 10k, N=0 refused, pages complete. The continuation point itself is the server's RawPageLimit planning, covered by the offline tests above, not by a live OPC UA session. A true end-to-end gate (OPC UA client reading a year at N=0 against a server on the SqlServer/QuestDb backend) needs the docker-dev rig, which I was told not to use.

Mishap: two aborted SQL Server probe attempts (row-by-row seeding was too slow, so I killed them) each left a throwaway OtOpcUaHistorian_T_* database on otopcua-mssql. Both were dropped manually. The final check shows no OtOpcUaHistorian* databases remain.

NOT RUN

  • Hardware userGates (real PLCs): AbCip / AbLegacy / TwinCAT array writes, FOCAS MACRO: > 32767, AbCip probe-typo recovery.
  • docker-dev gates A, B, C, D, E, and the Galaxy gate F: not in scope. The otopcua-dev rig is off-limits, and F needs mxaccessgw.
  • Modbus exception_injection / dl205 / mitsubishi / s7_1500 profiles (only standard requested); S7 reconnect-bounce and connect-timeout-outage destructive tests; AbCip Logix Emulate profile; Sql MySQL and ODBC (no fixture).

Cleanup

Container h1-gate-questdb removed. The fixture host is unchanged. The worktree scratchpad/wt/otopc-fix (branch w18/fixtures) is left in place with uncommitted probe edits. Remove it with git worktree remove --force if not wanted.

Gate D (#745) — AdminUI live pass, docker-dev (2026-11-09) — FAIL (1 of 5 checks)

Overall: four checks PASS; the delete-confirmation check FAILS on four pages (filed as a follow-up issue, see #761). Full record below, as written by the Chrome pass.

Date: 2026-11-09 (container clock 2026-11-10 03:xx UTC). Rig: local docker-dev, otopcua-host:dev image built 2026-11-09 20:35 EST from master 1b9c36f3 (per coordinator; rig built from a scratchpad worktree no longer on disk, so commit not re-verifiable from the container). AdminUI http://localhost:9200 (login disabled, session "multi-role-test"). Wonder boxes untouched. No deploy performed.

1. 100 KB script survives save + reload - PASS

Procedure: /scripts/new, set Monaco model to 102,492 chars (1,200+ comment lines + return 1;), name gateD-100kb-test, Create. Reloaded /scripts/SC-d82b3a36fd85; editor text === regenerated original (identical, length 102492 both). Then deleted it (own modal).

2. Two-tab /hosts CPU flat with ~50 drivers - PASS

Rig has 52 drivers on cluster MAIN (48 int3b-mb-*, blackhole, Calc, opcplc, pymodbus); site-a/b have none. docker stats --no-stream on central-1/central-2 (the AdminUI hosts), 8 samples ~12s apart (~100s) per condition:

  • no /hosts tab (on /): central-1 11.0%, central-2 10.9% avg
  • 1 /hosts tab (first run): c1 9.7%, c2 11.4%
  • 2 /hosts tabs: c1 10.9%, c2 12.1% avg Within noise (range 8-17% in every condition). Flat. Raw: scratchpad/cpu-*.txt.

3. 5k-tag device renders < 2 s - PASS

No such device existed. Created disabled-in-effect (never deployed) Modbus driver gateD-bulk/device gdev under docker-dev, imported 5000-row CSV via the Raw tree modal (parse+review 206 ms, commit 717 ms). Expanding gdev: first tag rows in DOM 14-20 ms after click, painted (double rAF) 44 ms. Tree paged: 500 rendered ("Show 500 more (4500 not shown)") and DOM ~611 nodes. Far under 2 s. Cleanup: tags cannot be bulk-deleted in UI (device delete: "Delete blocked - contains tags"), so removed gateD-bulk rows (5000 Tag, TagGroup, Device, DriverInstance) by scoped SQL delete in a transaction on the rig OtOpcUa DB (needed sqlcmd -I). Verified 0 gateD DriverInstances, Tag total back to 55. Observation (not a defect): no bulk "delete all tags in device" in Raw tree.

4. Every delete asks for confirmation - FAIL (4 defects)

window.confirm overridden; no native confirm ever fired. Own modal confirmed + Cancel verified (item survives) for: script delete (ScriptEdit), API key delete (/apikeys), all 10 UNS-tree deletes on /uns (enterprise, clusters, area, line, equipment), Raw-tree driver delete and device delete (modals). Equipment-page virtual-tag/alarm/reference deletes: source uses ConfirmDeleteVirtualTag/ConfirmDeleteAlarm/ConfirmRemoveReference; no live rows on the sampled equipment (NOT exercised live). Certificates delete uses RequestAction -> Confirm panel (source). DEFECTS - delete executes immediately on click, no confirmation: a) /role-grants Delete (RoleGrants.razor:68 -> DeleteAsync(r.Id)). LIVE-VERIFIED: added test grant cn=gated-test..., clicked Delete, row gone instantly, no modal. (Test grant thus already removed.) b) ClusterEdit.razor:102 @onclick="DeleteAsync" (cluster delete) - source only c) NodeEdit.razor:119 @onclick="DeleteAsync" (cluster node delete) - source only d) AclEdit.razor:151 @onclick="DeleteAsync" (node ACL delete) - source only (CollectionEditor.razor:31 per-row Delete in driver forms is a working-copy edit committed on Save - not counted.) Repro for (a): http://localhost:9200/role-grants, add any group, press its Delete.

5. A6 diagnostics panel shows real counts - PASS

/fleet/diagnostics?node=central-1:4053 : DRIVERS 52, FAULTED DEVICES 0, CONFIG SOURCE live, REVISION e221b1792cbd..., per-driver rows (48 HEALTHY int3b-mb-* with 1 connected device each, Calc/opcplc/pymodbus HEALTHY, int3b-blackhole INITIALIZING 0 connected). /hosts shows the same 52 driver instances and blackhole INITIALIZING "1 / 1 DOWN". Disabled int3b-bulk correctly absent. Minor: blackhole's down device is not counted in "FAULTED DEVICES 0" (state INITIALIZING, not faulted) - arguably fine, flag for A6 owner.

Revert state

Test script, test role grant, gateD-bulk driver/device/5000 tags all removed; no deploys; browser tab closed. Rig config as found.

Still not run after gate D

  • Gate F (Galaxy) — run 2026-11-11, see "Gate F (#735)" below.
  • Hardware userGates (real PLCs), as listed under NOT RUN above.

Gate F (#735) — 20 config deploys keep mxgateway.sessions.open flat (docker-dev + VD03 DEV mxaccessgw, 2026-11-11) — PASS

Commit tested 5ac55205 (master). Image otopcua-host:dev sha256:47ca0da6eaf7 rebuilt from that clean checkout before the run; all six nodes recreated from it.
Gateway DEV mxaccessgw on wonder-app-vd03.zmr.zimmer.com (gRPC h2c :5120, assembly 0.1.2.0, main 2b13564e). Metrics from its anonymous Prometheus endpoint http://wonder-app-vd03.zmr.zimmer.com:5130/metrics (mxgateway_sessions_open, …_sessions_opened_total, …_sessions_closed_total, …_workers_running, …_workers_killed_total, …_faults_total), scraped every 15 s from the Mac for the whole run. Read-only Galaxy access, owner-approved. No remote command ran on any wonder box.
Reachability The docker-dev containers resolved and reached wonder-app-vd03:5120 through the Mac's VPN route (/dev/tcp check from central-1) before anything was configured.
Driver gatef-gal, DriverType GalaxyMxGateway, cluster MAIN, seeded straight into the ConfigDb: gateway.endpoint = the VD03 gateway, useTls=false, apiKeySecretRef=env:GALAXY_MXGW_API_KEY, mxAccess.clientName=OtOpcUa-761-gateF, repository.watchDeployEvents=false, no alarmAck fallback. Device vd03 ({}); two Read-only tags: ScanState (DevWinPlatform.ScanState, Boolean) and CPULoad (DevWinPlatform.CPULoad, Float64). No writes, no alarm subscriptions, no acks.
Secret The VD03 gateway key reached the containers only as the GALAXY_MXGW_API_KEY runtime env (exported from the owner's env file into the docker compose up shell). It is not in the repo, a compose file, the ConfigDb or this record.
Respawn trigger As in gate A: each iteration edits the driver's config (JSON_MODIFY of mxAccess.publishingIntervalMs = 1000 + i), POSTs /api/deployments, waits for it to seal, waits 15 s, then scrapes the gateway. A config change is a respawn (#665), so every iteration stops and disposes the old GalaxyDriver and spawns a new one on both central-1 and central-2 (MAIN runs its drivers on both nodes) — 40 driver respawns in total.

Attribution. The gateway's metrics have no per-client label (the session table with client names is on the login-gated dashboard, which was not used), so the rig's share is measured as the delta against a baseline taken before the driver existed, cross-checked against the opened/closed counters. During the whole window the other consumers' sessions did not churn: opened − closed moved only by what the rig did.

Gateway samples (UTC):

when open opened closed workers running
baseline, 13 samples 23:11:15–23:14:11 (no Galaxy driver on the rig) 2 3 1 2
driver added + deployed (23:14:34) 4 (+2: one session per central) 5 1 4
iterations 1–20, 15 s after each seal (23:15:13–23:20:56) 4 in 13, 3 in 4 (it. 5, 6, 11, 16), 2 in 3 (it. 1, 17, 18); never above 4 5 → 42 1 → 38 2–4
settled, 23:21:04 / 23:21:09 / 23:21:25 4 42 38 4

The rig opened 39 sessions over the run (42 − 3) and closed 37 (38 − 1); the net +2 is exactly its two live drivers. The open gauge returned to its steady value (baseline + 2) after the 20 deploys, so no session leaked. The dips to 2–3 are samples that landed while a respawned driver was still reconnecting (see the init timeouts below), not lost sessions; every iteration ended with both drivers connected.

Gateway-side counters over the run: workers_exited{worker-shutdown-ack} 1 → 37 (graceful closes), workers_killed{OpenSessionFailed} 0 → 13, workers_killed{client-close} 0 → 1, faults{WorkerFaulted} 0 → 14, workers_exited{WorkerFaulted} 0 → 1, grpc_streams_disconnected{ClientCancelled} 31.

Rig side (per central, 23:14:56–23:21:10): 20 spawned GalaxyMxGateway driver gatef-gal, 20 stopped driver child, 20 disposed GalaxyMxGateway driver on stop, 25 GalaxyDriver gatef-gal shutting down (20 respawns + 5 failed-init teardowns), 15 GalaxyMxSession connected, 5 initialize failed: … timeout of '00:00:05'. central-2 logged one GalaxyMxSession close failed or timed out (best-effort) — dropping the channel. No error or fault otherwise.

Driver health after the run (/hosts, /fleet/diagnostics): gatef-gal on central-1 and central-2 Healthy, 2 tags, host status "2 / 6 down" (the probe watcher seeded 5 platforms; two Galaxy platforms on VD03 DEV report down, unrelated to the gate). Client.CLI read of both tags on 4840 and 4841 was Good before the loop (ScanState True, CPULoad ≈ 13.4) and after it (CPULoad 15.63, Good).

Removal and the final drop — not observed (VPN dropped)

The Mac's VPN route to VD03 went down at about 23:21:35, ten seconds after the settled samples. DNS for *.zmr.zimmer.com and the 10.220.x route both disappeared; it was still down at 23:40. The rig's Galaxy drivers lost the gateway (reconnect supervisor backing off, Initialize breaker opened), which is the expected behaviour.

gatef-gal (driver, device, 2 tags) was deleted from the ConfigDb and the removal deployed at 23:30:56 (12d5ff59, Sealed). Both centrals logged stopped driver child gatef-gal and disposed GalaxyMxGateway driver on stop. Because the gateway was unreachable, the rig could not close its two sessions; the gateway reaps a session whose client stream is gone on its detach-grace / lease sweep. The return of sessions.open to 2 was therefore not observed. Re-check mxgateway_sessions_open once the VPN is back: it should read the baseline (2, if VD03's own consumers are unchanged).

Observations (none blocks the gate)

  1. A Galaxy (re)start often exceeds the 5 s Tier-A Initialize timeout against VD03. 10 of 40 respawns (5 per node) failed initialize at exactly 5 s. Opening a gateway session spawns an x86 MXAccess worker, and a successful connect took 4–5 s from spawn to connected, right at the limit. Each timeout cost a 10 s reconnect-timer retry (BadNoCommunication on that driver's nodes meanwhile) and shows on the gateway as an OpenSessionFailed worker kill plus a WorkerFaulted fault. The gateway cleaned up every one (the gauge stayed flat), so it is a latency and noise issue, not a leak. Galaxy probably wants a longer Initialize timeout (tier or per-instance ResilienceConfig). Not filed (no Gitea per brief).
  2. The VD03 gateway's faults_total is now 14 (WorkerFaulted) from this gate — anyone reading the VD03 dashboard after 2026-11-11 23:15Z should attribute those, and the 13 OpenSessionFailed kills, to this run.
  3. The rig's opens (39 at the gateway) exceed its logged GalaxyMxSession connected lines (30 in the loop plus 2 on add); the extra opens are ones that completed after the client's 5 s init timeout had already given up. They were closed by the end of the window (opened − closed = 2 at 23:21).

Rig state left behind

  • gatef-gal and its device/tags are deleted from the ConfigDb; the removal deploy sealed. The gate's Deployment rows carry only the env: reference, no secret.
  • All host nodes were recreated without the exported key, so GALAXY_MXGW_API_KEY is back to the committed docker-dev default. The rig runs otopcua-host:dev sha256:47ca0da6eaf7 (5ac55205).

Re-run on current master — all runnable gates (2026-11-12)

Commit tested 54c4357f = master 412da27c + the deploy-API fix below (landed on master as fa8c3d1a after a rebase over two unrelated commits; same diff). Image otopcua-host:g761 sha256:24432d7fbc2d, built from that commit, on all six nodes.
Rig docker-dev as its own compose project otopcua-g761 (an overlay remapping every host port and image tag), so the shared otopcua-dev rig that someone else was running was not touched. The ConfigDb was restored from a COPY_ONLY backup of the otopcua-dev ConfigDb (the same MAIN-modbus / MAIN-opcua / equipment EQ-32296cc68964 + okvt config the 11-09 run used), then migrated (no pending migrations). Host ports: OPC UA 24840–24845, AdminUI 29200, per-node HTTP 29100–29105.
Clock Rig UTC 2026-11-13 03:05–03:44 (= 2026-11-12 evening US Eastern).
Load The 48 int3b-mb-* and the int3b-blackhole drivers were disabled after gate A. With them on, two rigs' worth of Modbus drivers saturated the shared pymodbus sim on 10.100.0.35 (about 117–136 % CPU) and HR100 read BadCommunicationError. Gate D's 50-driver checks were not re-run (see D).

Summary: A, B, C, D (check 4), E, H1 (live), the F removal check and every fixture lane PASS. One product defect found and fixed (deploy API). Hardware userGates still open.

Defect found and fixed: a bodyless POST /api/deployments answered 404 — FIXED (fa8c3d1a)

The documented headless deploy (docker-dev/deploy.sh, README "Headless deploy") returned 404 on both centrals, while the same POST with a JSON body was accepted. The optional body was an inferred DeployRequest? parameter, which stamps Accepts(application/json) metadata on the endpoint. For a request with no Content-Type the routing AcceptsMatcherPolicy prefers endpoints that declare no consumes metadata. On a fused admin+driver host MapOtOpcUaMeshGrpcServices has mapped grpc-dotnet's {unimplementedService}/{unimplementedMethod} catch-all, so the request was routed away from the deploy handler and got a 404. The deploy API on its own does not reproduce it. The handler now reads the optional body itself: no body is the headless form, a non-JSON body answers 415 and malformed JSON 400. DeployApiBodylessPostTests (Host.IntegrationTests) composes the deploy API with the mesh gRPC services and drives real HTTP; with the fix reverted both bodyless cases fail with 404. Live on the rebuilt rig, a bodyless POST through Traefik answered 202 (every deploy below used it).

Gate A (#731) — PASS, now with dotnet-counters

Method as on 11-09 (each iteration edits MAIN-modbus TimeoutMs and MAIN-opcua SessionName, deploys, waits for the seal + 8 s), 2 warm-ups + 20 iterations, every deploy sealed in 1–2 s. This time dotnet-counters collect -p 1 --counters System.Runtime --refresh-interval 5 (the linux-arm64 single-file tool copied into each central) ran for the whole loop, alongside /proc/1 threads / fds / RSS.

central-1 central-2
disposed Modbus driver on stop / disposed OpcUaClient driver on stop 20 / 20 20 / 20
spawned … MAIN-modbus / MAIN-opcua 20 / 20 20 / 20
dispose/shutdown timeout or throw 0 0
threads (baseline → range over 20 → settled 75 s later) 54 → 49–57 → 48 53 → 48–56 → 46
open fds 739 → 691–739 → 715 739 → 704–740 → 719
dotnet.assembly.count (57 / 54 samples) 312, constant 314, constant
dotnet.timer.count first-quarter vs last-quarter mean 139 → 137 144 → 145
dotnet.thread_pool.thread.count (UpDownCounter deltas) net ≈ 0 net ≈ 0
working set first-quarter vs last-quarter mean 520 → 505 MB 522 → 502 MB

No upward trend in any series; nothing (assemblies, timers, threads, fds) accumulates per respawn. Raw CSVs: counters-central-{1,2}.csv in the run's evidence (not committed).

Gate B (#732) — PASS

Same procedure as 11-09 on opc.tcp://localhost:24840, session 1 held 120 s on ns=2;s=pymodbus/plc/HR100 and ns=3;s=EQ-32296cc68964/okvt.

  • Surgical add (HR101, 03:14:27): both centrals logged kind=PureAdd, added=1, … rebuild=False. Each subscription took one BadNoCommunication sample at 03:14:27 and was Good again in the same second (the accepted driver respawn), then 21 / 22 Good updates until the rebuild. HR101 itself reads BadOutOfRange (the fixture refuses address 101, as on 11-09).
  • Rebuild (rename e1 → e1r, 03:14:49): kind=Rebuild, … rebuild=True; session 1's items got BadNodeIdUnknown and nothing after.
  • Re-created after the rebuild (session 2, 20 s): HR100 17 updates, okvt 16, all Good, none Bad.

Gate C (#733) — PASS

  • C-a, kill central-1 mid-dispatch. site-b-1 paused, deployment 56e5ede1 at AwaitingApplyAcks 5/6, docker kill central-1 at 03:16:37. A second POST while in flight: 409. After unpausing site-b-1 the held deployment sealed 6/6 (on 11-09 it timed out). After central-1 restarted, the next deploy 6df32531 sealed 6/6.
  • C-b, kill the singleton host. central-2 (the only /health/active 200 on MAIN) killed at 03:17:30 with 548ccbb9 in flight (5/6). central-1 auto-downed it at 03:17:58, the singletons moved (Younger -> BecomingOldest), the AdminUI was back through Traefik at 03:18:02, and at 03:18:14 central-1 logged Coordinator recovered in-flight deployment 548ccbb9… (5/6 acks landed). It closed TimedOut with failureReason = Timed out waiting for acks (5/6 landed). No ack from: site-b-1:4053. … (the #784 fix, verified live). After central-2 restarted, the next deploy 246594e0 sealed 6/6.
  • C-port. As on 11-09, a replacement container from the compose service with a perl listener holding 0.0.0.0:4840 before dotnet OtOpcUa.Host.dll starts (the 4840 socket inode belongs to perl, none to dotnet):
    • fused admin+driver (central-2): GET /health/ready 503, opcua-endpoint: Unhealthy — OPC UA SDK failed to start: Failed to establish tcp listener sockets on port 4840 for Ipv4 and IPv6.; configdb/akka Healthy.
    • driver-only (site-a-2): FTL OtOpcUaServerHostedService: SDK start failed on a driver-only node …, exit 1.
    • So the C3 behaviour holds on OPC UA SDK 2.0.1. (A first attempt was invalid: the Perl one-liner did not keep the socket in a variable, so Perl closed it at once and the SDK bound 4840 normally. Re-run with the socket held.)
  • Side effect worth knowing: the 39 s docker pause of site-b-1 in C-b outlived the 15 s auto-down, so site-b-2 downed it and the SITE-B pair came back as two 1-member clusters, both Active, until site-b-2 was restarted. That is the documented auto-down trade (docs/Redundancy.md, restart one side after the link heals), not a defect, but every C-style drill that pauses a node past 15 s leaves this behind.

Gate D (#745) — check 4 re-run after #785: PASS

Checks 1, 2, 3 and 5 passed on 11-09 and their code is unchanged. Check 4, the one that failed, was re-run with Playwright against http://localhost:29200 (login disabled), with a native-confirm listener armed (it never fired):

page Delete click Cancel Confirm
/role-grants (throwaway grant cn=g761-gateD,…) modal "Delete role grant" naming the group; row still in DB row kept row deleted (0)
/clusters/SITE-B/acls/g761-acl (throwaway ACL) modal "Delete ACL"; row still in DB row kept row deleted (0)
/clusters/SITE-B/edit modal "Delete cluster 'SITE-B'? This cannot be undone." modal closed, cluster kept not confirmed (real row)
/clusters/SITE-B/nodes/site-b-2:4053 modal "Delete node 'site-b-2:4053' from cluster 'SITE-B'? …" modal closed, node kept not confirmed (real row)

Gate E (#734) — PASS

gateE-spin (var v = ctx.GetTag("pymodbus/plc/HR100").Value; while (true) { }) as change-triggered virtual tag spinvt on EQ-32296cc68964, deployed 03:27:21, sampled every ~4 s for 1 min 50 s:

  • Quarantined on both centrals at 03:27:42 (21.4 s / 22.3 s, over 10 × the 2 s budget); /metrics otopcua_script_quarantined_total{reason="stuck",script="gateE-vt-spin"} 1, run_refused_total 8 / 11. The main-log mirror now names the script ([Script] gateE-vt-spin emitted Error …, the #784 fix).
  • Responsive: /health/ready 200 in 2–6 ms throughout (2.9–3.5 ms before); okvt kept reading Good, connect + read 0.40–0.48 s (0.41–0.52 s before); spinvt BadInternalError.
  • Threads flat: central-1 42–44, central-2 41–45 (41–42 before).
  • CPU: both centrals went to ~1 core (≈100 ticks/s) until restarted after the gate — the known in-process limit (#750; the out-of-process runner is the fix).

H1 (#736) — live end-to-end over OPC UA: PASS

This closes the 11-09 scope caveat (that run drove the backend directly). ServerHistorian:Backend=SqlServer on the centrals (DB OtOpcUaHistorian on the rig SQL), 52,560 samples (every 10 min, 2025-11-01 → 2026-10-31) seeded for HR300 (lg9.long). A probe client (OPC UA SDK 2.0.1) sent HistoryRead raw with NumValuesPerNode = 0 for the whole year on opc.tcp://localhost:24840 and followed every continuation point:

page values continuation point
1 9,999 16 bytes
2–5 9,998 each 16 bytes
6 2,569 none

52,560 values in total (= the seeded set: first 0, last 52559), timestamps strictly ascending, no duplicates, 0.2 s. central-1 GC heap after the read: gen2 48 MB + LOH 56 MB + gen1 7 MB (≈ 112 MB), committed 170 MB — under the 500 MB bound. QuestDb was not re-run live: the paging is the backend-agnostic node-manager RawPageLimit plan, and the QuestDb backend half passed on 11-09.

Gate F (#735) — the removal drop, now observed: PASS

The 11-11 run could not see mxgateway_sessions_open return to baseline after removing the driver (the VPN dropped). The VD03 gateway has since restarted (its counters reset), so this was re-checked directly: a read-only GalaxyMxGateway driver g761-gal (one Read tag, DevWinPlatform.ScanState; key only as env:GALAXY_MXGW_API_KEY, sourced into the compose shell from the owner's env file and never written anywhere) was added to MAIN and then removed. Gateway /metrics (anonymous, scraped from the Mac):

when (UTC) open opened_total closed_total workers
baseline 03:40:49–03:41:19 2 2 – 2
driver deployed 03:41:47 (both centrals connected, ScanState read Good) 4 4 – 4
driver removed 03:42:58 → 03:43:13 … 03:43:59 2 4 2 2

Both centrals logged stopped driver child g761-gal + disposed GalaxyMxGateway driver on stop; the gateway closed both sessions within 15 s. No Galaxy writes, no alarm subscriptions.

Driver fixture lanes (10.100.0.35) at 412da27c — PASS

Suite Result Notes
Modbus standard (:5020) 12 / 0 / 32 Two earlier attempts failed 8 then 1 tests on connect timeouts: the shared sim was saturated by this gate's own rig plus otopcua-dev (196 connections from this Mac). Clean once the load dropped. Skips are the other profiles.
S7 s7_1500 (S7_SIM_ALLOW_WRITES=1) 23 / 0 / 3 Same 3 skips as 11-09 (TM/CT span limit, the two destructive CMD tests).
AbCip controllogix, AB_SERVER_ALLOW_WRITES=1 AB_DIFF_ENDPOINT=… 34 / 0 / 22 No write test runs in this combination: IntegrationWriteGate refuses writes while AB_DIFF_ENDPOINT is set (read-only differential run). That was also true of the 11-09 "with writes" run.
AbCip controllogix, AB_SERVER_ALLOW_WRITES=1 only 38 / 0 / 18 The real write pass: all 10 write tests pass, incl. AbCipRefusalLiveTests.A_whole_array_write_round_trips (DINT[16]) and AbCipUdtMemberWriteTests.A_member_array_element_write_leaves_the_other_elements_alone, Native + libplctag.
OpcUaClient (opc-plc :50000) 9 / 0 / 0 One flake on the first try (ServerHostNameValidationTests…(validate: False), BadRequestInterrupted); clean rerun.
OpcUaClient.Browser (+ OPCUA_NAT_ENDPOINT=…:50010) 12 / 0 / 0 The NAT fixture is up, so the #806 NAT tests ran too.
MQTT + Sparkplug 30 / 0 / 0
Sql (SQL Server, PG, Oracle, blackhole) 58 / 0 / 25 21 MySQL + 4 ODBC skips (no fixture).
MTConnect (G1, :5000) 21 / 0 / 0 First run of the G1 MTConnect lane against the shared agent.

Hardware userGates — simulator evidence only; real hardware still required

userGate Simulator evidence (2026-11-12, 412da27c) Real hardware
AbCip array write ab_server ControlLogix: whole-array DINT[16] round-trip + UDT member-array element write pass (above) not run
AbLegacy array write ab_server SLC500 / MicroLogix / PLC-5: N_file_array_write_round_trips_every_element passes on all 3 families × 2 transports; AbLegacy suite 51 / 0 / 0 not run (#752)
TwinCAT array + STRING write none — no ADS simulator exists (elma-ads-tcp/udp on 10.100.0.35 are socat relays to an absent ADS box); suite 0 / 0 / 18 skipped not run (#755)
FOCAS MACRO: > 32767 otopcua-focas-fixture mock (byte-identical to the repo's focas_mock/server.py): WireBackendCoverageTests.Macro_and_parameter_numbers_above_short_range_read_the_authored_variable (MACRO:99999, PARAM:40000, with a decoy at the 16-bit-wrapped address) passes; FOCAS suite 18 / 0 / 0 not run
AbCip probe-typo recovery AbCipRefusalLiveTests.A_typod_probe_tag_does_not_black_out_a_reachable_device passes on ab_server, Native + libplctag not run

Plan INT-4 marks these "USER-ORDERED — NON-SKIPPABLE … real ControlLogix / SLC / TwinCAT hardware", so the simulator passes do not close them. They need bench endpoints from the owner, or an explicit owner decision to accept the simulator evidence for the four that have it (TwinCAT has none).

Observations

  1. The bodyless-deploy 404 (fixed above) had been masked because every earlier gate run sent a JSON body.
  2. Two docker-dev rigs on one Mac saturate the shared pymodbus sim; disable the 48 int3b-mb-* drivers (they exist only for gate D's 50-driver checks) when not running those.

Rig state left behind

The otopcua-g761 project (containers, its volumes, the otopcua-host:g761 / otopcua-migrator:g761 images), the g761-probe network and the temporary OtOpcUaHistorian database (inside the project's SQL volume) were all removed. The shared otopcua-dev rig was only read (one COPY_ONLY backup to its container's /tmp, deleted straight after). Nothing was started or stopped on 10.100.0.35.

Owner decision (2026-11-12)

The simulator evidence is accepted for the hardware gates: AbCip array writes, AbLegacy array writes (SLC500/MicroLogix/PLC-5 simulators), FOCAS MACRO: > 32767, and AbCip probe-typo recovery. TwinCAT has no simulator; its hardware proof stays with lmxopcua#755. lmxopcua#761 is closed.