docs(mesh): MeshTransport config, Phase 2 as shipped, design-doc correction

Rig: all six docker-dev nodes carry MeshTransport__Mode plus both central
contact points. Mode is "${OTOPCUA_MESH_MODE:-Dps}", so the rig still comes up
on the transport it has always used and the live gate can flip the whole fleet
at `docker compose up` without a compose edit or a rebuild.

Docs: a MeshTransport section in Configuration.md (including why contact points
are addresses only, why the contact set does not scope delivery, and why
buffer-size=0 is a behaviour decision rather than a tuning knob), a "Command
transport" section in Redundancy.md contrasting the two modes, and a summary
beside the Redundancy notes in CLAUDE.md.

Corrects the design doc's claim that ScadaBridge replies with a typed failure to
every unhandled message. Neither of their comm actors has a ReceiveAny or an
Unhandled override; the idiom is per message type and fires on a missing
registration, and an unknown type dead-letters there as it does anywhere else.

Records the exit-gate deviation in the program plan: "an Ask timing out cleanly
against a stopped node" cannot be run, because no Ask crosses this boundary. The
honest equivalents are a stopped node failing the deploy at the apply deadline
and an unreachable contact dropping the command with a Warning.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 17:46:46 -04:00
parent 4eca87f04d
commit e4cb9a08e0
6 changed files with 202 additions and 5 deletions
+31
View File
@@ -407,6 +407,37 @@ Net effect: each alarm transition appears **once** on `/alerts` and would histor
See [ScriptedAlarms.md](ScriptedAlarms.md) and [AlarmTracking.md](AlarmTracking.md) for the scripted-alarm engine internals.
## Command transport (central↔node)
Everything above rides one Akka mesh: central publishes on DistributedPubSub, every node
subscribes, and the two ends must be members of the same cluster. Per-cluster mesh **Phase 2**
adds a second transport under `MeshTransport:Mode` (see
[Configuration.md § `MeshTransport`](Configuration.md#meshtransport-centralnode-command-transport))
that does not need shared membership — the prerequisite for splitting the fleet into one mesh per
application `Cluster`.
| | `Dps` (default) | `ClusterClient` |
|---|---|---|
| Central → node commands | `Publish` on `deployments` / `driver-control` / `alarm-commands` | `ClusterClient.SendToAll` to `/user/node-communication`, re-emitted on the node's **local** `EventStream` |
| Node → central acks | `Publish` on `deployment-acks` | `ClusterClient.Send` to `/user/central-communication`, forwarded to the deploy-coordinator singleton |
| Requires shared cluster membership | **yes** | no |
Two properties are load-bearing and easy to break:
- **Both comm actors are registered per node, NOT as cluster singletons.** A `ClusterClient` rotates
across its contact points and must find a live comm actor at whichever node answers. As a
singleton the boundary would be reachable through exactly one node, and rotation would fail
silently against the others — acks landing or not depending on where the client happened to
settle. `akka.cluster.client.receptionist.role` is deliberately empty for the same reason.
- **Inbound commands go to the node-local `EventStream`, never back onto a DPS topic.** DPS is
mesh-wide and central `SendToAll`s to *every* node, so a DPS re-publish would deliver N copies of
every command to every subscriber.
Phase 2 changes no redundancy semantics: the Primary gate, ServiceLevel, and the singleton layout
are untouched, and `redundancy-state` itself stays on DPS (it is bidirectional and built from
`Cluster.State`, which makes it genuinely mesh-bound — the hardest remaining dependency before the
meshes can split).
## Pair-local store (LocalDb — Phases 1 + 2)
Independently of the ServiceLevel machinery above, every **driver-role** node keeps a consolidated