# Per-cluster mesh Phase 1 — live gate record

**Date:** 2026-07-22 · **Rig:** `docker-dev` (six-node single mesh) · **Branch:** `feat/mesh-phase1`
**Plan:** [`2026-07-21-per-cluster-mesh-phase1.md`](2026-07-21-per-cluster-mesh-phase1.md)

**Result: PASSED**, after the gate found a blocker that changed the design (step 4).

Offline tests cannot cover the substitution's real risk — that the DB set and the live set disagree on
a running fleet. This is that check.

## Setup

Migration applied by the rig's `migrator` service against the shared `OtOpcUa` database, then all six
host nodes restarted on the rebuilt image. **All six pre-existing `ClusterNode` rows inherited
`AkkaPort = 4053` from the migration's `DEFAULT 4053`** — the row-level evidence that the default does
its job for rows predating the column:

```
central-1:4053|central-1|4053|NULL|1
central-2:4053|central-2|4053|NULL|1
site-a-1:4053 |site-a-1 |4053|NULL|1
site-a-2:4053 |site-a-2 |4053|NULL|1
site-b-1:4053 |site-b-1 |4053|NULL|1
site-b-2:4053 |site-b-2 |4053|NULL|1
   NodeId     |  Host   |Akka|Grpc|En
```

## Step 1 — deploy with all six nodes up → **PASS**

`POST /api/deployments` → `202 Accepted`, deployment `38f83b37`.

`Deployment.Status = 2` (Sealed). Six `NodeDeploymentState` rows, every one `Status = 1` (Applied).
**No FK 547** — the DB-derived NodeIds satisfy the `NodeDeploymentState → ClusterNode` foreign key,
which is the standing proof that the DB set and the membership set agree on this rig.

## Step 2 — *dropped, not skipped*

The plan called for a cluster-scoped deploy. **There is no such thing.** `Deployment` has no
`ClusterId`, `ConfigComposer.SnapshotAndFlattenAsync` always snapshots the whole DB, and
`DeploymentArtifact.ResolveClusterScope` is *node-side* self-scoping of a fleet-wide artifact. Removed
from the plan rather than faked green.

## Step 3 — deploy with one node stopped → **PASS** (the new behaviour)

`docker stop otopcua-dev-site-b-2-1`, then deploy `37d72301`.

`Deployment.Status = 4` (TimedOut) after the 2-minute apply deadline. Five nodes `Applied`,
`site-b-2:4053` still `Applying`. Under the membership rule this deployment would have **sealed
green** with five nodes and never mentioned the sixth.

The operator-facing log, which the plan required to be legible:

```
[12:52:02 WRN] Deployment 37d723018e374c0eb04c7b6beccc3b3e timed out after 00:02:00 (5/6 acks
landed). No ack from: site-b-2:4053. Each is an enabled ClusterNode row — start the node, or set
ClusterNode.Enabled = 0 if it is down for maintenance, then redeploy
```

(The remedy in that message was corrected to `MaintenanceMode = 1` — see step 4.)

## Step 4 — the escape hatch → **FAILED, then fixed and re-run → PASS**

### First attempt: the hatch did not exist

`UPDATE ClusterNode SET Enabled = 0 WHERE NodeId = 'site-b-2:4053'`, then deploy:

```
HTTP 422
[ClusterEnabledNodeCountMismatch] Cluster 'SITE-B' declares NodeCount=2 but has 1 Enabled nodes.
Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.
```

`DraftValidator.ValidateClusterTopology` rejects the deployment **before it dispatches**. So
`Enabled = 0` cannot be used on a node of a 2-node cluster — and every cluster on this rig, and every
cluster in the program plan's target topology, is a 2-node pair.

**This was a blocker, not a wrinkle.** The plan called step 4 *"the check that makes step 3
acceptable to ship"*. Without it, a node down for maintenance blocks every deployment to its cluster,
with no remedy short of editing `NodeCount` / `RedundancyMode` — which changes the runtime redundancy
posture to work around a deploy gate.

The two rules were each reasonable and contradictory together: the validator reads `Enabled` as *"part
of the declared topology"*, Phase 1 additionally read it as *"expect an ack"*.

### Fix (Task 7): split the meanings

New `ClusterNode.MaintenanceMode bit NOT NULL DEFAULT 0`. `Enabled` keeps its topology meaning and the
validator is untouched; the coordinator's expected-ack set and the address reconciler both filter on
`Enabled && !MaintenanceMode`.

### Re-run: PASS

`docker stop` `site-b-2` + `MaintenanceMode = 1`, then deploy `82c5e678`:

- `202 Accepted` — the topology gate passes, because the node is still `Enabled` (`1|1`).
- `Deployment.Status = 2` (Sealed).
- **Five** `NodeDeploymentState` rows, all `Applied`. `site-b-2:4053` has no row at all — excluded
  from the expected set rather than waited for and forgiven.

## Task 4 (reconciler) — live behaviour, not a planned gate step

Observed unprompted during the rolling restart, which is better evidence than a staged check:

```
[12:44:49 WRN] ClusterNode address check [EnabledRowNotInCluster] site-a-1:4053: ...
[12:44:49 WRN] ... site-a-2:4053 ... site-b-1:4053 ... site-b-2:4053
[12:44:56 INF] ClusterNode addresses reconcile with cluster membership (6 driver members)
```

It warned per row while the site nodes were still starting, then converged to a single clean line —
and **logged nothing further**, confirming the change-detection guard suppresses repeats on the
5-minute sweep.

Maintenance interaction, from the step 4 re-run:

```
[13:06:02 WRN] ... site-b-2:4053 ...            ← node dropped, flag not yet set
[13:06:05 INF] ... reconcile with cluster membership (5 driver members)   ← flag set: silent
```

The transient warning is honest — for those three seconds the row genuinely was enabled, present in
the expected-ack set, and absent from the cluster.

## What this gate did not cover

- **Phase 2's premise.** `AkkaPort` / `GrpcPort` are groundwork; nothing dials them yet, so "the value
  is correct" is only checked by the reconciler comparing it to gossip. The first real exercise is
  Phase 2.
- **The reconciler under split meshes.** On the current single mesh an admin node sees every driver
  member. After Phase 6 it will not, and every site row would report `EnabledRowNotInCluster`
  permanently. Recorded as a known limitation on the class and in the design doc.
- **`Enabled = 0` on a single-node (`RedundancyMode.None`) cluster.** Untested — the rig has no such
  cluster. It would fail the same topology rule (0 enabled vs `NodeCount = 1`), so `MaintenanceMode`
  is the hatch there too.
