feat(config): add ClusterNode.AkkaPort/GrpcPort — central's dial targets

Per-cluster mesh Phase 1 groundwork. Once the meshes split (Phase 2) central
can no longer see a site node's appsettings, so the transport ports it must
dial have to live in a row central can read.

AkkaPort is non-nullable with a 4053 default — every node listens on a
remoting port, so 0 is never a truthful value and pre-existing rows must
migrate to something real. GrpcPort is nullable with no default: nothing
listens on it until Phase 5, and a non-null default would assert a port that
does not exist.

Both are documented as central's dial targets rather than the node's own
binding config; the duplication against Cluster:Port is reconciled in Task 4.

The new SchemaCompliance test is red until the migration lands (Task 2).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 08:22:46 -04:00
parent e7311f11a6
commit 2bbb02713c
4 changed files with 162 additions and 0 deletions
@@ -18,6 +18,33 @@ public sealed class ClusterNode
/// <summary>The dashboard HTTP port (default 8081).</summary>
public int DashboardPort { get; set; } = 8081;
/// <summary>
/// Akka remoting port (default 4053). <b>This is central's dial target, not the node's own
/// binding configuration</b> — the node binds from <c>Cluster:Port</c> in its own appsettings,
/// and this column duplicates that value for a reader that cannot see the node's config.
/// Per-cluster mesh Phase 2 builds its ClusterClient contact points from
/// <see cref="Host"/> + this port, at which point central and the site node no longer share a
/// gossip ring and central has no other way to learn it.
/// </summary>
/// <remarks>
/// The duplication is real and unenforced by the schema. <c>ClusterNodeAddressReconciler</c>
/// on the admin node compares this row against observed cluster membership and logs an Error
/// on mismatch — a node that binds 4054 while its row says 4053 is unreachable from central in
/// Phase 2, and the symptom there is a silent absence of acks rather than an error.
/// </remarks>
public int AkkaPort { get; set; } = 4053;
/// <summary>
/// gRPC port central dials for the Phase 5 telemetry stream, or <see langword="null"/> when the
/// node exposes none. Also central's dial target rather than the node's binding config.
/// </summary>
/// <remarks>
/// Nullable by intent: nothing listens on this port yet, and a non-null default would assert a
/// port that does not exist. Phase 5 populates it; until then <see langword="null"/> is the
/// honest value.
/// </remarks>
public int? GrpcPort { get; set; }
/// <summary>
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
/// Fleet-wide unique index enforces no two nodes share a value.