diff --git a/.claude/skills/sister-project-history/otopcua.md b/.claude/skills/sister-project-history/otopcua.md index 1074223..4e9bc51 100644 --- a/.claude/skills/sister-project-history/otopcua.md +++ b/.claude/skills/sister-project-history/otopcua.md @@ -5,3 +5,26 @@ | Project | Location | Stack | Repo | Summary | |---|---|---|---|---| | **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree (dual-namespace) address space (v3.0, merged to `master` 2026-07-16, PR #472, merge `ec6598ce`)** — native-protocol drivers (Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient), a **read-only `Sql` DB-poll driver** (polls DB tables/views on an interval → OPC UA variables; `connectionStringRef` env resolution, schema-walk address picker, live-gated on docker-dev — merged to `master` 2026-07-24. **Three providers ship as of 2026-08-05** (merge `d54f3c83`): **SQL Server**, **PostgreSQL** (managed `Npgsql`) and **Oracle** (managed `Oracle.ManagedDataAccess.Core`) — no native/thin-client installs; `MySql`/`Odbc` remain reserved enum names that fail at construction. Each dialect owns its own parameter markers/bind names + `ConfigureCommand` (Oracle sets `BindByName`, and its binds carry a `p_` prefix because Oracle rejects a bind named after a reserved word — ORA-01745). Live-gated against dedicated fixtures on `10.100.0.35` (postgres `:15432`, oracle-free `:11521`): env-gated integration suites 11/11, plus a chrome/AdminUI gate on docker-dev (provider dropdown authoring, Test-connect, `/raw` browse + browse-commit) that found and fixed a SqlServer-only gate left in the Test-Connect probe, merge `4e461951`), an **`Mqtt` broker-subscribe driver with Sparkplug B** (one MQTTnet-5 client per driver, TLS+auth, hand-rolled reconnect; **Plain** mode binds a tag to a concrete topic, **SparkplugB** mode decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple — birth/alias/seq-gap/rebirth state machine, death→STALE, a birth-driven browse picker with a scoped Request-rebirth NCMD; live-gated on docker-dev against a Mosquitto TLS+auth broker + a project-owned C# Sparkplug edge-node simulator on `10.100.0.35:8883` — **merged to `master` 2026-07-27, merge `c3a2b0f7`, pushed to origin**; three follow-ups since, all live-gated and merged to `master`: **(1) write-through is no longer deferred** (Gitea #508, merge `2a7d1167`, 2026-07-31, 21/21 live) — MQTT nodes are **writable** where the tag declares a destination: a Plain tag publishes to a new `writeTopic` TagConfig key, a Sparkplug tag encodes an **NCMD/DCMD** command. **Consumer-visible to any OPC UA client, i.e. ScadaBridge:** an MQTT node now materializes `SecurityClassification.Operate` instead of `ViewOnly` when its tag declares a write destination, and `BadNotWritable` when it does not — browse-time writability is computed by the same encoder the write path uses, so a node can never advertise writability it would then refuse. `Good` means **published**, not applied: MQTT has no application-level ack, so the write posture is optimistic-Good with the standard write-outcome self-correction behind it. **(2) Sparkplug primary-host STATE publishing** (Gitea #511, merge `857a10b4`, 2026-07-31, 5/5 live) — `actAsPrimaryHost` was accepted by config and did nothing; it now publishes a retained `ONLINE` on `spBv1.0/STATE/{hostId}`, arms the matching `OFFLINE` as the MQTT **Last Will**, and publishes an explicit `OFFLINE` on clean shutdown. The claim is bound to its **own dedicated broker connection** because a Will can only be registered in the CONNECT packet, and it is **Primary-gated** — only the node holding the redundancy Primary role claims, since an ungated pair would let a *Secondary's* death publish `OFFLINE` over a healthy Primary and stop every edge node in the group. New driver-facing seam `IDriverPrimaryRoleSource` in `Core.Abstractions`, published by `DriverHostActor` off the data-plane gate verdict. Accepted trade: a brief edge-visible `OFFLINE` window during failover. **Not an OPC UA wire-contract change** — this is what OtOpcUa asserts to *other applications on the MQTT broker*. **(3) structured Sparkplug metrics** (Gitea #515, merge `857a10b4`, 3/3 live) — `Template` (UDT) and `DataSet` metrics were skipped with a warning; they now **flatten to sibling tags** (`Motor` → `Motor/Speed`, `Motor/Stator/Temp`; `Recipe` → `Recipe/Step/0`, `Recipe/Target/1`, row index always present so a growing table never renames existing tags), and a metric's `PropertySet` projects to node **metadata**, never tags. Flattening was chosen over OPC UA Structures precisely so the result is consumable by any client with no type dictionary — **relevant to ScadaBridge: a Sparkplug UDT now surfaces as ordinary scalar nodes** rather than being invisible. Skip-and-warn survives for everything still unflattenable (template definitions, extension values, malformed datasets, payloads past the depth/member bounds), and refusal is **whole** — never a partially-flattened UDT), a **read-only `MTConnect` Agent driver** (one hand-rolled `System.Xml.Linq` HTTP/XML client per driver against a vendor-neutral MTConnect Agent — `/probe` builds the device model, `/current` primes, and a `multipart/x-mixed-replace` `/sample` long-poll pump feeds `ISubscribable.OnDataChange` with ring-buffer sequence-gap re-baselining and a heartbeat watchdog; `UNAVAILABLE` → `BadNoCommunication`; the TrakHound MTConnect.NET dependency was evaluated and **dropped** — neither pinned package can parse an MTConnect document or frame the sample stream socket-free; browse comes free from the Wave-0 universal discovery browser, so the driver ships no browser project; **write-back is deferred, every MTConnect node is read-only**; live-gated on an isolated docker-dev stack against a real `mtconnect/agent:2.7.0.12` + SHDR-adapter fixture on `10.100.0.35:5000` — **merged to `master` 2026-07-27, PR #506, merge `90bdaa44`, pushed to origin**), **and AVEVA System Platform (Wonderware) Galaxy, now a standard Equipment-kind driver** (the old SystemPlatform mirror / alias-tag model was retired ~2026-06-12). **v3.0 NodeId/namespace scheme (this is the wire contract ScadaBridge binds against — a CUTOVER for its Data-Connection-Layer bindings; ScadaBridge is already namespace-URI-durable via `OpcUaNodeReference` and its #14 phase 2 shipped 2026-07-23 (main @ `266f001a`): `OpcUaReferenceForm.IsDurable` + a browse-picker nudge to the durable `nsu=;s=…` form + doc sweep. **ScadaBridge #14 is CLOSED (2026-07-24) — ScadaBridge consumes v3 end-to-end.** The live gates that were deferred to a real v3 rig all ran against the local `otopcua-dev` v3 cluster and PASSED: phase-2 `nsu=` hardening (`266f001a`), a DCL host-rewrite fix (`86d129de`), and the raw-path + native-alarm live gates (items B/C). Item C's premise was obsoleted by OtOpcUa #17; the item-A binding re-author (legacy `ns=2` collides with v3 `raw`) is downstream data-only work, not a code gap. Closing comment `#issuecomment-5223`. Scope record: ScadaBridge `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`):** the single `https://zb.com/otopcua/ns` namespace is replaced by **two** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal, NodeId `s=///`); every value has ONE source (the raw tag) fanned to both NodeIds (identical value/quality/timestamp), each UNS variable `Organizes`-references its raw node, writes route through either NodeId, HistoryRead works via both under one historian tagname, and native alarms fan (one `ReportEvent`) to the raw device folder + every referencing equipment folder. The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**. (v3 program = Batches 1–4: greenfield Raw-tree schema + `/raw` authoring UI + Calculation driver + UNS reference-only Equipment + `{{equip}}` reference-relative scripts, all merged; Batch 4 lit up the address space.) Galaxy access flows through the in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. Surfaces live read + authorized write, native OPC UA Part 9 alarms, and server-side HistoryRead. **Address-space availability guarantee (matters to any OPC UA consumer, i.e. ScadaBridge — issues #485/#486, merged `master` 2026-07-21, live-gated on docker-dev):** a deployment artifact the node cannot read is treated as *no answer*, never as *an empty configuration*. Previously a transient ConfigDb error mid-deploy emptied the served address space (a `PureRemove` of every node — observed live, and it stayed empty), stopped every driver and cleared every subscription, while still reporting the deploy Applied; a consumer would have seen all bindings go bad. The node now holds its last-known-good address space, drivers and subscriptions, and reports the deploy **Failed** without advancing its revision so the retry lands. **Historian backend (merged 2026-06-27, PR #423):** OtOpcUa's sole historian read/write backend is **HistorianGateway** (via `ZB.MOM.WW.HistorianGateway.Client` — continuous historization + alarms), replacing the retired bespoke Wonderware historian driver. **Redundancy corrections (2026-07-21, `master @ 0de1352e`) — both consumer-visible:** (1) the downing strategy moved from SBR `keep-oldest` to Akka `AutoDowning` (`Cluster:SplitBrainResolverStrategy`, default `auto-down`, 15s window), because a two-node pair on `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node** — `KeepOldest.OldestDecision` only rescues a side holding >=2 members, so the 1-vs-1 survivor downs *itself* and exits. Same fix and same rationale as ScadaBridge `cf3bd52f`; accepted trade is dual-active during a real partition. **Its live gate is now CLOSED** — Phase 7 drill D4 (2026-07-24, `c50ebcf7`) ran the 1-vs-1 crash-the-oldest drill on the three-mesh rig and every survivor stayed Up without self-downing. (2) The redundancy **Primary is now the oldest Up `driver` member, not `RoleLeader` (lowest address)** — the two diverge after any node restart, so the Primary-gated data plane could enable on a node not hosting the cluster singletons; this is the node whose `ServiceLevel` reads 250 vs 240, so **a consumer selecting by ServiceLevel may now prefer a different node than before after a restart**. `NodeRedundancyState.IsRoleLeaderForDriver` renamed `IsDriverPrimary`. The election is still scoped **per Akka cluster, not per application `Cluster`** — the per-cluster mesh design (`docs/plans/2026-07-21-per-cluster-mesh-design.md`) closes that; **PROGRAM COMPLETE — Phases 0a/0b/1/2/3/4/5/6/7 all done + merged to `master` and pushed (Phase 7 `c50ebcf7`, bootstrap guard `d1dac87f`, 2026-07-24; Phase 6 was `2839deb5`, Phase 5 `35552a96`).** **Phase 2 (merge `6eaa81ca`)** added a second command transport behind the `MeshTransport:Mode` dark switch (`Dps` default), the prerequisite for splitting the fleet into one mesh per application `Cluster` (central and node no longer need shared gossip membership) — originally an Akka `ClusterClient`. **That transport has since been migrated off Akka entirely (ClusterClient→gRPC migration, 2026-08-06): Phases 0/1A/1B/2 are MERGED to `master` and pushed** (merges `10b6d209`/`b360239e`/`7203d135`; Phase 2 cutover `caf2eec6` flipped the docker-dev rig default to `Grpc`, live gate PASS 8/8 + 135-min soak clean, full suite green on the cutover SHA) — cross-mesh commands now ride gRPC: central→node `NodeCommandService` on the `:4056` telemetry listener, node→central deploy acks `CentralCommandService/ReportApply` on central's `:4055`, per-cluster PSK bearer auth, fail-closed, fire-and-forget with the deploy-deadline + boot-replay backstops unchanged. **Phase 3 (full `ClusterClient` deletion — code, the HOCON receptionist/client block, `CentralContactPoints`, the `ClusterClient` mode value; `SplitTopologyTransportValidator` now requires `Grpc`) is MERGED to `master` @ `2fa880b6` and pushed** (2026-08-06; branch carried 7 deletion steps + reviews + a docs sweep; DoD full suite exit 0, 50/50 suites, 9,326 passed / 0 failed). **Phase 4 (terminal live gate) PASSED 8/8 the same day — the ClusterClient→gRPC program is CLOSED** (gate record `d0ac6fc5`, run on `master @ 0b194003`, provenance-guarded post-deletion image): zero cross-mesh Akka associations (exactly 3 established `:4053` connections rig-wide, all pair-internal), zero receptionist/ClusterClient log lines across every drill, PSK negatives fail-closed both services incl. unset-key boot refusal, exactly-once ack matrix, central-kill/site-kill/mid-deploy-coordinator-kill drills all honest (one loud WRN per drop, +2:00 deadlines, boot-replay, no dupes — a coordinator killed in the dispatch second had its deadline fired by the recovered coordinator on the surviving central at exactly +2:00), terminal full suite RC=0 direct capture 50/50 suites 9,333/0. Sole re-carry: the I-4 cold-PSK-cache latency measurement, structurally unmeasurable on docker-dev (`ClusterPsks` answers ahead of the store), deferred to the first store-backed deployment. One non-mesh observation: a same-second restart of the central pair reproduced the documented guard-off cold-start split-brain (Phase 7 `Cluster:BootstrapGuard` is the opt-in mitigation, default OFF) — healed by a single-node rejoin; both site pairs re-formed clean. Not an OPC UA wire-contract change — internal mesh transport only, no ScadaBridge impact. **Phase 3 (merge `d01b0695`)** added config **fetch-and-cache**: behind the `ConfigSource:Mode` dark switch (`Direct` default = today's behaviour), a driver node fetches its deployed-configuration artifact **from central over a gRPC stream** (SHA-256-verified against the dispatch's `RevisionHash`), caches it in `ZB.MOM.WW.LocalDb`, and reads config **only** from that cache — never central SQL. **Consumer-relevant:** this extends the #485 availability guarantee — a `FetchAndCache` driver node **boots and serves its last-known-good address space with central SQL Server completely down** (restoring from the LocalDb pointer, no central dependency at boot), and a fetch that fails is treated as *no answer* (apply fails, last-known-good kept), never an empty configuration. The Phase 3 live gate caught + fixed a serve-gate deadlock (central had gated the artifact serve on `Status==Sealed`, but a deployment seals only after every node acks and a fetching node can't ack until it fetches). **Phase 4 (merge `26fad75c`, live-gated on docker-dev; Task-9 table drop `3a590a0c`)** cut the driver-side ConfigDb connection entirely: a **driver-only** node (`driver`, not `admin`) now boots with **no ConfigDb connection string at all** — config via FetchAndCache into LocalDb, scripted-alarm Part 9 condition state in a replicated LocalDb table, and central persists its deploy acks. **Consumer-visible ServiceLevel change (matters to any ServiceLevel-selecting OPC UA client, i.e. ScadaBridge):** DB-reachability is *retired* from the ServiceLevel calc on such DB-less nodes, so a healthy site node now publishes **240/250 even with central SQL Server completely down** (it has no DB connection to lose — survive-alone), where a DB-backed node whose ConfigDb went unreachable would drop to 0/100. So during a central-SQL outage a consumer keeps seeing the site nodes as fully authoritative rather than watching their ServiceLevel collapse. The old single-Akka-mesh election scope was unchanged as of Phase 4 (one Primary fleet-wide) — **Phase 6 (below) closes that.** **Phase 5 (merge `35552a96`, live gate PASSED 2026-07-23)** replaced the DPS fan-out of the four live-telemetry observability channels (`alerts`/`script-logs`/`driver-health`/`driver-resilience-status`) with a gRPC server-streaming contract — each **driver node hosts** the telemetry server and **central dials in** — behind the `Telemetry:Mode`/`TelemetryDial:Mode` dark switch (`Dps` default), fail-closed shared-bearer auth; `redundancy-state`/`fleet-status`/`deployment-acks` deferred with rationale. **Not consumer-facing to ScadaBridge** — this is central↔node AdminUI observability, not the OPC UA address space, with **no wire-contract change**. Also pinned `System.Security.Cryptography.Xml` 10.0.10 — the same four NU1903 advisories that broke ScadaBridge's fresh restores (`dced0d27`) were breaking OtOpcUa's too (204 errors on a clean restore, invisible locally because NuGet audit data is cached). **Phase 6 (MESH PARTITION — merged to `master` + pushed 2026-07-24, tip `2839deb5`; live gate RAN and caught three fix-forward defects — a boot-crash `StackOverflow` on every node from resolving `IClusterRoleInfo` inside the `AddAkka` configurator while the `ActorSystem` was still building, plus rig LDAP-missing on sites and a site-pair split-brain — all fixed, `792f28ec`)** split OtOpcUa's single fleet-wide Akka mesh into **one independent 2-node mesh per application `Cluster`** (central pair + one pair per site), the same shape ScadaBridge already runs — same `otopcua` ActorSystem name, separated purely by per-pair self-first seed partitioning, with central reaching each site mesh over the explicit ClusterClient/gRPC/ConfigServe transports (Phases 2/3/5) instead of shared gossip. **Consumer-visible (ServiceLevel-selecting clients, i.e. ScadaBridge):** the redundancy election is now scoped **per application `Cluster`, not per Akka mesh** — so there are now **two-or-more Primaries fleet-wide, one per `Cluster`, by design** (`RedundancyStateActor` is now a `cluster-{ClusterId}`-scoped singleton on every driver node). A consumer selecting by ServiceLevel therefore correctly sees one Primary (250) + one Secondary (240) **per application `Cluster`**, resolving the "one Primary fleet-wide" scope caveat noted under Phases 0b/4. **No OPC UA wire-contract change** — this is internal Akka topology + AdminUI/redundancy, not the address space. Also new: a fail-closed `SplitTopologyTransportValidator` refuses to boot a cluster-role node left on a DPS transport; secrets replication is now pair-local (no SQL-hub — sites have no SQL); the compiled transport-mode defaults stay `Dps` (the validator, not a default flip, is the guardrail). Plan `OtOpcUa/docs/plans/2026-07-24-mesh-phase6-partition-and-colocation.md`. **Phase 7 (`c50ebcf7`, 2026-07-24) closed the program:** 6/6 failover drills PASSED across all three pair types (graceful manual failover, auto-down both directions, crash-the-oldest, cold-start-alone, rejoin-as-Secondary), closing the auto-down gate open since Phase 0a. The manual-failover button is **MAIN-only by construction** (it acts on the local node's `Cluster.State` and the AdminUI runs only on the central pair) — site pairs fail over via auto-down only. A follow-on **bootstrap guard** (`d1dac87f`, opt-in `Cluster:BootstrapGuard:Enabled`, **default OFF**) closes simultaneous-cold-start split-brain: the lexicographically lower `host:port` node is the preferred founder, the higher node TCP-probes its partner and joins if reachable / self-forms if not — the order is chosen **before** the single `JoinSeedNodes`, so it can never re-form mid-handshake. **Deferment register (2026-07-27, `e08855fb`): `OtOpcUa/deferment.md`** — a source-verified inventory of unfinished work. **Two entries are consumer-relevant to ScadaBridge:** (1) **node-level ACLs are authored, deployed and NOT ENFORCED** — the `IPermissionEvaluator`/`TriePermissionEvaluator` subsystem has zero production consumers and `OtOpcUaNodeManager` never references it, yet operators can author `NodeAcl` rows that ship in every artifact; actual enforcement is a realm-qualified **write** gate plus an alarm-ack gate, so **reads are ungated and authored deny rules have no effect** (do not assume OtOpcUa filters an address space per-user). **⚠️ Changed 2026-08-01 (Gitea #520, merged to `master` + pushed, merge `fbe7403c`): the OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine, so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an `otua__` credential as the password, minted on the AdminUI `/api-keys` page (shown once). `LdapOpcUaUserAuthenticator` is deleted; **LDAP survives for AdminUI login only.** **This does not break ScadaBridge today** — `OpcUa:AllowAnonymous` still defaults to `true`, and an anonymous session is now a normal grantable principal (`anonymous`) rather than a bypass, arriving at the same role gates a key does; set it `false` to make keys mandatory. A key's grants still collapse into the two server-wide gates and **only `Cluster`-scoped grants count** — honouring a narrower grant's flags while unable to honour its scope would turn "WriteOperate on Equipment X" into server-wide write authority. Keys reach a driver-only node inside the deployment artifact (the peppered HMAC only, never a secret), so **revocation is asymmetric: immediate on a node with a ConfigDb, next-deployment on a driver-only one.** (2) **a driver-reported remote change is an operator PROMPT, never an address-space rebuild** (`feat/deferment-remediation`, 2026-07-27, Gitea #518) — `IRediscoverable` was inert fleet-wide and is now consumed: a Galaxy redeploy / TwinCAT symbol-version bump / MTConnect Agent restart / Sparkplug rebirth raises a **"re-browse" chip** on the AdminUI `/hosts` page (carried on `DriverHealthChanged.RediscoveryNeededUtc`/`.RediscoveryReason`). It is **advisory**: the served address space is deliberately NOT changed, because v3 authors raw tags through the `/raw` browse-commit flow — so **the served tree still only changes on a config redeploy**, which makes it more stable than the driver docs imply, not less. Gitea #507 (discovered-node injection) is **closed as superseded** and its code deleted — it read two structures that are structurally empty in v3, so it could never have injected anything. **`IHostConnectivityProbe` is now consumed** (Gitea #521, merged `master` `abda6f9e` 2026-07-30, live-gated on docker-dev): eleven drivers computed per-host connectivity that nothing read. It now rides `DriverHealthChanged.HostStatuses` to a **Hosts column on the AdminUI `/hosts` page**, showing the case the driver-level state chip structurally cannot — a multi-device driver staying aggregate-Healthy while one of its PLCs is unreachable. Resolved *neither* way the issue framed it: the `DriverHostStatus` table's intended publisher (driver nodes upserting rows) was **unbuildable** after Phase 4, since a driver-only node has no ConfigDb connection — so the capability was kept, the transport moved to the Phase 5 telemetry stream, and the permanently-empty table was **dropped**. **Not consumer-facing to ScadaBridge** — AdminUI observability, no OPC UA wire-contract change. **That register's remediation is MERGED to `master` and pushed** (2026-07-28, merge `914d70bf`) — all six ranked items plus two deferred follow-ups: ACL non-enforcement made explicit in docs + UI warnings (wire-up = Gitea #520), the rediscovery signal above, **#516 driver config edits no longer silently discarded** (a changed `DriverConfig` now stop+respawns the driver instead of an in-place delta that five drivers ignored while the deploy sealed green — **operationally visible: a config edit now costs a driver reconnect**), AdminUI dispatch-map parity guards, plan bookkeeping, and the driver **stability tiers documented as inert** (every driver runs Tier A), and that machinery has since been **DELETED** (Gitea #522, merge `abda6f9e` 2026-07-30) — `MemoryTracking`, `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor`, the vestigial `DriverTypeRegistry`, and the operator-authorable `RecycleIntervalSeconds` knob are gone. Deleted rather than activated because the premise was gone, not merely unused: "Tier C" meant an out-of-process driver Host a supervisor could restart, and none exists anywhere (Galaxy is gRPC-to-mxaccessgw since PR 7.2, FOCAS in-process since 2026-04-24), so `IDriverSupervisor` had zero implementations. The inertness was also broader than filed — nothing constructed the trackers outside their own unit tests, so "pass real tiers" was never a flag flip. **`DriverTier` itself survives and is load-bearing** (`GetTierDefaults` supplies the real per-capability timeout/retry/breaker policies). Two follow-ups the first pass had deliberately left unbuilt also landed: Sql/FOCAS now re-parse config in place too (each derives its connection/backend from config, so the parse was made **atomic** — options + dialect + connection string, or options + backend, adopted together — rather than half-applying a change), and tag-set drift detection (FOCAS/TwinCAT/MTConnect/AbCip re-browsed every 5 min and raised the same `/hosts` prompt when the device's tag set differed from what was authored) — **which was REMOVED two days later and is NOT in `master`** (Gitea #523, merge `2a0461a1`, 2026-07-30): the check was recurring, unconfigurable (interval was a constructor parameter with no appsettings key) and never live-gated outbound traffic to real PLCs/CNCs/Agents, and how often to browse someone's plant equipment is an operational decision rather than an implementation detail. **Consequence to carry forward:** AbCip and FOCAS browse a live backend but implement no `IRediscoverable`, so they now have **no tag-change signal at all** — a PLC re-download is invisible until an operator re-browses by hand; Galaxy/TwinCAT/MTConnect/Mqtt raise natively and are unaffected. **Not consumer-facing to ScadaBridge either way** — no OPC UA wire-contract change, and the `IRediscoverable` prompt above is untouched. That same round (merge `2a0461a1`) also retired 13 stale skipped tests (6 revived onto the v3 raw/UNS shape, 3 folded into a widened parity corpus, 4 deleted as retired), added a reflection guard over the deploy-time TagConfig gate after finding MQTT shipped an `Inspect()` nobody called, fixed three OpcUaClient integration tests that had been silently failing on `master` (v3 made the driver resolve references through its authored `RawTags` table, so the tests' bare `ns=3;s=…` node ids failed locally at the resolver and never reached the wire), and wrote the two missing driver guides (`docs/drivers/Sql.md`, `docs/drivers/Calculation.md` — Sql had shipped with no user-facing doc at all). A third tracker round merged **2026-07-31 (`9fa701b1`)**: Gitea **#526** split `ISqlDialect`/`SqlServerDialect` into a `Driver.Sql.Dialect` leaf so the Sql **browser** no longer drags the runtime driver — and, through it, `ZB.MOM.WW.OtOpcUa.Core` — into the AdminUI's build/deploy graph (measured absent before/after; this was the remaining cause #512 had wrongly attributed to MQTT), guarded now by a csproj-graph test over **every** `*.Browser` project; **#525** deleted the unconsumed `IDriver.GetMemoryFootprint` / `FlushOptionalCachesAsync` across all 12 drivers plus the support code that only fed them; **#481** bridged driver comms-loss into scripted-alarm input quality and made the engine distinguish a never-pushed input from a driver reporting Bad. **Consumer-visible to ScadaBridge, with a caveat:** a scripted-alarm condition whose input reports Bad with no value now reports Bad `Quality` on the wire where it previously read Good, and virtual tags computed from such an input go Bad too — the *comms-loss* half was initially unreachable, because the #481 live gate found that `DisconnectObserved` had **no production sender**: a driver whose device died mid-run was never detected, never reported comms loss, and did not recover when the device returned (live-observed on Modbus; a node restart was needed). **That is now FIXED and the caveat is withdrawn** — Gitea **#527**, merged `master` `916f0f79` 2026-07-31, live-gated twice on docker-dev. A connected driver whose device dies is declared disconnected once it has gone 90 s without a successful read **while also** reporting itself non-Healthy, which emits `ConnectivityChanged(false)` and drives both quality paths (#477 native conditions, #481 scripted-alarm inputs), and it **re-initialises and recovers by itself when the device returns** (~8 s in the gate, no node restart). **So OtOpcUa condition Quality can now be relied on to signal that a PLC went away** — with the honest bound that detection is delayed by up to ~2 minutes by design, not instant. The trip condition is deliberately a conjunction rather than "non-Healthy for N polls": `Degraded` means *connected but some data is bad*, and drivers that write health per-tag inside their read loop latch `Degraded` against a perfectly healthy device when one authored address is unreadable, so a state-only rule would have reconnected working drivers every 90 s forever. Also fixed in the same change: the reconnect retry now tears the transport down before re-initialising, since Modbus and MQTT overwrite an `IAsyncDisposable` field in `InitializeAsync` and would otherwise leak a socket per retry. **AB CIP / AB Legacy honoured the wrong TCP port until 2026-08-01** (Gitea **#534**, merged `master` `94990990`): both libplctag drivers carried the authored EtherNet/IP `Port` on their tag-create params and consumed it nowhere, and libplctag does not fault on a portless gateway — it substitutes 44818. So a device authored on a non-default port was not merely unreachable; the connection was silently redirected to whatever answers on 44818, which on a plant network is a different PLC whose tags would then read `Good`. Anyone who concluded that AB devices must live on 44818 can stop working around it. **The discovery half of that defect survived until 2026-08-06 as Gitea #539, now FIXED** (merge `5b9ff9d0`): the AbCip “Browse controller” path still passed the bare host to libplctag, so browsing a device authored on a non-default port silently returned the symbol table of whatever answers on 44818 — an operator could commit tags against the wrong PLC. Both discovery sites (symbol-list enumerator + UDT template reader) now compose `host:port`, a source-scan guard test pins every libplctag Tag construction in both AB drivers to the composed form, and the fix was live-gated against the fixture host (browse on `:44818` returns the nine-tag table; browse on a dead `:44999` — the issue's exact repro — now fails instead of redirecting). Found only because the AbLegacy PCCC test fixture had never actually been deployed — it now runs one simulator per family on its own port, and that suite passes for the first time. **The FOCAS fixed tree became publishable for the first time on 2026-08-05** (Gitea **#540**, merged `master` `4e22f024`): browse-committing a fixed-tree leaf produced a tag the driver could never read, so the whole `Identity`/`Axes`/`Spindle`/`Program`/`OperationMode`/`Timers` surface could not reach the address space through any v3 authoring path — v2 auto-injected it and #507 removed that injection as superseded by a browse-commit route that did not work. A captured browse leaf's id **is** its driver `FullName`, and the fixed tree emitted its internal cache key `{deviceHost}/{path}` there; both authoring paths wrote that verbatim into the tag address, no FOCAS parser accepts it, and the driver skipped every such tag at init — so all 20 committed tags on the wonder Makino read `BadNodeIdUnknown` forever while the deploy sealed green and the driver reported Healthy with 0 errors. Fixed at the emitter rather than by stripping the host at commit, which repairs the `/raw` browse-commit and the manual picker together: a fourth address form `FIXED:` (e.g. `FIXED:Identity/SeriesNumber`) served from the driver's own poll-loop snapshot, read-only, and carrying **no device** — the tag's Device row already routes it, and the duplicate routing channel was the root cause rather than the parse failure. **Consumer-visible to ScadaBridge: new FOCAS nodes can now appear** where the fixed tree is authored (they could not before), they are `ViewOnly`, and their quality is honest — values are dated from the last answered poll and go Bad after 8 poll cadences (floored at 30 s) instead of serving a dead CNC's last snapshot as Good indefinitely. Two authoring-side guardrails shipped with it (the tag modal and the deploy-time inspector now run the real address parser, so a bad address is rejected where it is authored rather than silently skipped at driver init), and the manual picker — which had offered `axis`/`spindle`/`program`/`status` "parameter groups" that are not FOCAS addressing spaces, defaulting to the unusable `axis:5` — now composes PMC/`PARAM:`/`MACRO:` through the parser itself. **The other half of #540 was split out as Gitea #541 and is now FIXED** (merged to `master` + pushed, merge `355e59aa`, 2026-08-06, live-gated on docker-dev over two rounds). Every driver skipped an unmappable tag at init with only a log line, so **a driver could report Healthy / 0 errors / a live-advancing Last read while ignoring most of its tags** — that is no longer invisible. A new optional `ISkippedTagReporter` capability over a shared `SkippedTagLedger` carries the authored count, the exact skipped count and a capped sample of RawPath + reason on `DriverHealthChanged`, surfacing as a **Tags column on the AdminUI `/hosts` page** (`20 / 22 skipped`; red when a driver is polling *nothing*, which is the state most likely to look healthy because it has no failing reads to report). Transport is the Phase 5 telemetry stream, **not a table** — the #521 lesson, since a driver-only node has no ConfigDb. **The issue's own count was wrong: it named eight drivers, and it is twelve** — Calculation, MTConnect, Galaxy and OpcUaClient were unlisted, and OpcUaClient's was the quietest of the twelve (a bare `continue` in `NamespaceMap` with not even a log line). A reflective fleet guard over every `IDriver` now pins the invariant so a thirteenth driver cannot re-introduce the silence. Sql reports the **union of its two drop points** — the blob that does not map *and* the §8.1 catalog gate — because schema drift (a renamed column, a dropped view) produces the second, and reporting only the first would have it claim `0 skipped` while polling half its tags. The signal is **additive to driver state, never `Degraded`**: latching non-Healthy for a config problem would satisfy both halves of #527's comms-loss conjunction and reconnect-loop a perfectly healthy device whose tags were all skipped. **Not consumer-facing to ScadaBridge** — AdminUI observability, no OPC UA wire-contract change; a skipped tag's node still materializes and still reads `BadNodeIdUnknown` exactly as before. What changes is that an operator can now *see* it. The live gate found one defect the unit tests could not (a driver that faulted before building a tag table reported `0 tags` against its real authored count — a positive claim made on behalf of a driver that never looked; `GetSkippedTags()` now returns null until the first committed build), and surfaced **three drivers on the dev rig that had been silently skipping their tags for an unknown length of time**, each reporting Healthy with zero errors. The duplicate-sibling-axis residual (the wonder Makino returns two axis folders both called `AA`, which the commit gate refused and whose name-keyed runtime cache let the second overwrite the first) is **FIXED as Gitea #548** (merge `8b5c478c`, 2026-08-06): duplicated sibling names — axes AND spindles — are occurrence-suffixed in slot order (`AA_1`/`AA_2`; unique names stay bare, so nothing renames on ordinary machines), the emitter/snapshot-keys/servo-loads/spindle-resolution all consume one shared segment list so they cannot disagree, and the bare name of a duplicate set deliberately reads `BadNodeIdUnknown` rather than one arbitrary axis's data. Red-first offline tests plus a live focas-mock wire gate; not yet deployed to wonder (the box runs `4e22f024`) — authoring the Makino's `Axes/` needs that deployment plus a re-browse. #540 itself is CLOSED as of 2026-08-06. **Deployed to `wonder-app-vd03` the same day** (box `4e461951` → `4e22f024`; no EF migration in the delta, so a plain binary swap): the 20 dead FOCAS tags had their addresses rewritten in place to the `FIXED:` form rather than being re-browsed — an exact 1:1 transform that preserves every RawPath/Name/DataType/TagGroup/UNS reference, so nothing downstream rebinds — and they now read **20/20 Good** over OPC UA with real machine data (Series `G431`, 7 axes, mode `AUTO`, program `//CNC_MEM/USER/LIBRARY/O1234`), where they had been 0/20 since the v3 cutover. Nothing else in that merge was consumer-visible: the `NodeAcl` scope model gained a variable-depth `NodeScope` plus the scope-path producer its trie builder had always required (Gitea #520 stage 3). **Node ACLs ARE now enforced, but only behind an opt-in flag (Gitea #520 stage 5, `master` 2026-08-01).** `Security:Acl:Enabled` defaults to **`false`**, and with it off nothing changes for a consumer: the two coarse server-wide gates (`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods) remain the whole of data-plane authorization and HistoryRead stays ungated. **With it on, scoped `NodeAcl` grants decide EVERY data-plane operation** — read, browse, subscribe, write, alarm commands and HistoryRead (#520 stages 5 + 6, `master` 2026-08-01, live-gated on docker-dev) — and the scoped decision **replaces** the server-wide role rather than adding to it. **What this means for ScadaBridge — this changed on 2026-08-01 and the previous entry said the opposite:** reads, browses and subscriptions are ungated only while the flag is **off**. On a node where an operator turns it on, a read-only consumer is affected too: **every** operation ScadaBridge performs — browsing the address space, reading, subscribing, and its writes / alarm acks / HistoryRead — is denied unless the principal it authenticates as holds a matching grant — and since `OpcUa:AllowAnonymous` still defaults `true`, that principal is the reserved `anonymous` one unless ScadaBridge is given a key. Granting `anonymous` the permissions it needs, or issuing it an `otua_` key and granting that principal, are both first-class options. Two consequences of stage 6 specifically. **Browse is needed to navigate, and a grant deep in the tree automatically makes the folders above it browsable** — otherwise the grant could not be reached — so a narrow grant still exposes the names of its ancestors. And **revoking a grant now stops a running subscription**: the monitored item receives one `BadUserAccessDenied` and then goes silent, and a later deployment restoring the grant resumes it with no client action, so ScadaBridge does not need to reconnect to recover. Both were verified live on docker-dev, not only in tests. Two behaviours worth knowing: an ACL or key change **reaches sessions that are already open** (each decision stamps the current deployment generation and re-checks the key, so revoking a key stops its live session rather than waiting for a reconnect), and enforcement **fails closed on every unresolvable case** — a misconfigured node denies everything rather than allowing everything, which is the safe direction but presents as a total outage of the three gated operations rather than a subtle permission bug. **#520 is now COMPLETE (stage 7, `master` `3796c0bf`, 2026-08-01, live-gated on docker-dev):** grants are authored through a scope picker over the cluster's real raw + UNS trees, which supplies the scope kind and target together, and both the AdminUI and the deploy gate now refuse a grant whose scope resolves to nothing. **Not consumer-facing to ScadaBridge** — authoring-side only, no OPC UA wire-contract or enforcement change; it matters only in that a grant issued to ScadaBridge's principal can no longer be saved pointing at an entity that does not exist. **Tracker zeroed 2026-08-10 — everything below is merged to `master` and pushed (origin now at `d06e92c6`; the repo had run ~45 commits ahead of origin over 2026-08-07→10 and is back in sync).** Six subagent rounds closed Gitea **#549–#559**, completing the **authoring-vs-deploy-gate parity arc**: every driver's AdminUI tag editor, the CSV import/export merge, the `/raw` authoring service, browse-commit, and the deploy-time `Inspect` gate now all consult the **driver's own runtime parse** as the single verdict authority (`TagConfigParseGate`; accept-gate-first Inspects with rejection-vs-advisory classified structurally), so what any authoring path accepts is exactly what the driver serves — the "authored green, reads `BadNodeIdUnknown` forever" class is closed fleet-wide. **Two served-behaviour fixes are consumer-visible to any OPC UA client:** a leading-whitespace MQTT TagConfig blob is now served instead of silently refused (#559 — MQTT was the one driver whose runtime mappers carried their own untrimmed framing gate; `JsonDocument.Parse` is now the fleet-wide framing authority), and an OpcUaClient tag authored with the legacy PascalCase `NodeId` key now survives an editor round-trip (#557 — the editor previously dropped the key on save, taking the tag dark on the next deploy). One deliberate authoring tightening: an untouched `{}` Modbus config is refused at save (#554 — it always read `BadNodeIdUnknown` under a green deploy). Also in that window: **connect-path resilience** — `IDriver.InitializeAsync` now runs through the per-capability invoker (timeout/breaker, `DriverCapability.Initialize`; R2-10, merge `1038c0e2`), closing the last uninvoked driver entry point, with the breaker threshold made cadence-reachable and a respawn invalidating the breaker tracker so a mid-outage deploy cannot strand a stale OPEN chip on `/hosts`. And a plan-task freshness sweep (2026-08-10, `d06e92c6`) live-verified Modbus-Int64 end-to-end authoring and the S7/AbCip Test-Connect probe E2E (12/12; the probe suite's skip-gating turned out to be pure TCP reachability — runnable from macOS, the "needs Windows-VM" note was stale) and closed the two plan tasks whose subjects v3 had deleted; open plan tasks are down to 3, all deliberate holds. None of the above changes the OPC UA wire contract beyond the two served-behaviour fixes named. | + +## 2026-10-07 — FOCAS / TwinCAT / AbCip driver fixes from ignitionoee + real-device gates + +The FOCAS and TwinCAT fixes built for the `ignitionoee` Ignition driver module (Java) were audited against +OtOpcUa's drivers and ported where OtOpcUa had the same defect; AbCip was exercised against the real +CompactLogix presses `ignitionoee` lists. All read-only against production equipment, except a user-authorized +AddRoute of the dev Mac (`oee-tagdump-mac`, `10.181.206.245.1.1`) on the two test Elma PLCs Z33856/Z33733. + +- **FOCAS** (6 silent mis-decodes, live-confirmed on Makino Z-34184): `cnc_rdmacro` decimal offset (vacant/fractional → Bad); + axis/spindle name records read all 4 bytes to first NUL (`AA_1`/`AA_2` → **`AA6`/`AA7`** — the #548 duplicate-AA workaround's + root cause); positions scaled by each `0x0026` block's base/exponent, fabricated `0x00D3` deleted; servo meter base/exponent; + spindle meters are 1/16384 of full scale → percent, **`Spindle/*/MaxRpm` renamed `MotorSpeedPct`**, `Load` Int32 → Float64; + missing program info → Bad not Good 0. **Client-visible: committed raw tags on the old axis/spindle paths must be re-browsed.** + Not ported: RunState enum/debounce (OtOpcUa has no run-state output), PARAM: decode (unverified both sides), all-axes batching. +- **TwinCAT** (Beckhoff ADS lib): leading-dot TC2 globals parse; TC2 type names `INT16`/`UINT16` browse (354 globals were dropped); + string subscriptions registered by PLC byte width (previously threw and tore down the whole native batch), NUL-terminated, + Windows-1252; type-kind/width mismatch → `BadTypeMismatch` per tag; strings sized by the resolved data type (lib mis-reports TC2 + member symbol sizes); `VAR_IN_OUT` string refs refused. **Array tags on native notifications now go Bad** (previously first element + at Good) — use `UseNativeNotifications=false` for arrays until native array subscribe exists. +- **AbCip** (libplctag; 80 Ignition tags all Good on Z-35162): real `@udt` template decode; program-scoped tags browsed; array dims; + module I/O paths (`Local:1:I.Data`) accepted; unknown tag no longer throttles its whole device (`BadNodeIdUnknown`/`BadTimeout`); + user-string arrays; width-vs-authored-type mismatch → `BadTypeMismatch`. Residual: same-width wrong type undetectable (libplctag + discards the reply type code).