Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
40 KiB
Per-Cluster Mesh Phase 5 — gRPC live-telemetry stream Implementation Plan
For Claude: REQUIRED SUB-SKILL: execute this with superpowers-extended-cc:subagent-driven-development (chosen for the program's prior phases). Fresh subagent per task; classification-driven review chain.
Goal: Replace the DPS fan-out of the live-telemetry observability channels with one gRPC
server-streaming contract — each driver node hosts the telemetry gRPC server (Kestrel h2c),
central dials in — so AdminUI live panels keep working once the meshes split (Phase 6) and no
longer depend on a shared Akka gossip ring for fleet observability. Ships as a per-node dark
switch (Telemetry:Mode = Dps default | Grpc), authenticated fail-closed from day one.
Architecture: Mirror ScadaBridge's SiteStreamService shape (recon'd 2026-07-23), adapted to
OtOpcUa's substrate. Node side: the four telemetry publish seams also emit into a node-local
in-process hub (ITelemetryLocalHub) — DPS publishing is left untouched, so the switch is pure —
and a streaming gRPC service fans the hub to connected clients through per-subscriber bounded
DropOldest channels. Central side: a supervisor actor discovers driver-node gRPC endpoints from
ClusterNode rows (Host + GrpcPort, added in Phase 1), keeps one reconnecting dialer per node,
converts streamed events back to the domain records, and feeds the exact same in-process sinks the
DPS SignalR bridges feed today (IInProcessBroadcaster<AlarmTransitionEvent>,
IInProcessBroadcaster<ScriptLogEntry>, IDriverStatusSnapshotStore,
IDriverResilienceStatusStore). The AdminUI components are untouched: only the bridge's upstream
swaps.
Tech Stack: .NET 10, Grpc.AspNetCore / Grpc.Net.Client (already in-repo via Phase 3),
Grpc.Tools codegen, Akka.NET, System.Threading.Channels, xUnit + Shouldly.
Scope (settled with the user 2026-07-23)
In scope — migrate these four node→central observability channels to the stream as four oneof
event kinds:
| DPS topic today | Message record | Central sink fed today |
|---|---|---|
alerts |
AlarmTransitionEvent |
IInProcessBroadcaster<AlarmTransitionEvent> (+ AlertHub) |
script-logs |
ScriptLogEntry |
IInProcessBroadcaster<ScriptLogEntry> (+ ScriptLogHub) |
driver-health |
DriverHealthChanged |
IDriverStatusSnapshotStore (+ DriverStatusHub) |
driver-resilience-status |
DriverResilienceStatusChanged |
IDriverResilienceStatusStore (no hub) |
Explicitly deferred, with rationale (do NOT migrate in Phase 5):
redundancy-state— bidirectional, built fromCluster.State, pair-local control-plane that drives ServiceLevel + the Primary gate (consumed byOpcUaPublishActor,ScriptedAlarmHostActor,DriverHostActor,HistorianAdapterActor). It stays on DPS in both MeshTransport modes today, and under Phase 6 it is pair-local and works in-mesh. Central's display of each pair's redundancy is a Phase 6 cross-mesh concern (possibly a later added event kind), not a Phase 5 observability panel.fleet-status— central-internal:FleetStatusBroadcaster(admin singleton) builds it from the admin node's own cluster membership/reachability/leader events;Fleet.razorpolls the ConfigDB and ignores the feed entirely. It is not a node→central stream, and its live UI path is already DB-polled. Revisit in Phase 6 when central loses gossip visibility of site nodes.deployment-acks— already rides the Phase 2 ClusterClient transport whenMeshTransport:Mode=ClusterClient; it is a command-plane reply, not telemetry.
This narrowing mirrors how Phases 1 and 3 honestly scoped down from the program sketch. The program doc's Phase 5 line lists all seven; this plan records the four that are genuinely live node→central observability and defers the rest with reasons above. Update the program doc + design §6.3 in Task 10.
Direction & the dark switch (read before any task)
- Node = server, central = client (the load-bearing ScadaBridge inversion). Telemetry
originates on driver nodes; central/admin consumes it. A fused
admin,drivernode both hosts (as driver) and dials (as admin) — it dials itself plus its pair peer, same as central dials site nodes. Telemetry:Modeis read at startup; both code paths are compiled into every binary. Flipping the flag is an appsettings/env change + restart — NOT a rebuild — exactly the Phase 2/3 dark-switch discipline (OTOPCUA_CONFIG_MODEon the rig). The node always hosts the gRPC server whenTelemetry:GrpcListenPort > 0and always emits into the local hub AND publishes DPS, in both modes — so central can ingest from either side without a node redeploy. Only central's ingest source switches:Dps→ today's four DPS SignalR bridges subscribe and feed the sinks;Grpc→ those four bridges are NOT spawned and the dial supervisor feeds the identical sinks instead.- Auth from day one. Reuse the fail-closed
FixedTimeEqualsbearer interceptor pattern (ConfigServeAuthInterceptor/LocalDbSyncAuthInterceptor). Shared node keyTelemetry:ApiKey(serve side) ==TelemetryDial:ApiKey(central side). This supersedes design §6.3's "unauthenticated for now" — ScadaBridge itself closed that gap with this same pattern.
Reuse map (from recon 2026-07-23 — exact sites)
- Proto + codegen: add
telemetry.protobesidesrc/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/deployment_artifact.proto; register a<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>inZB.MOM.WW.OtOpcUa.Commons.csproj:29(same block as the existing item). Generated types are shared by node (Runtime/Host) and central (AdminUI) from the one Commons reference. - Server hosting + Kestrel h2c: the dedicated-listener block
Program.cs:434-533and theMapGrpcServicegateProgram.cs:577-580. AddtelemetryListenPortalongsidesyncListenPort/configServeGrpcPort. - Auth interceptor: copy
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigServeAuthInterceptor.cs; add to the sharedAddGrpcpipeline atProgram.cs:425-431. - Client dialing:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/GrpcDeploymentArtifactFetcher.cs(channel-cache, Bearer metadata, h2cGrpcChannel.ForAddress, linked-CTS deadline). - Options + validator:
ConfigSourceOptions/ConfigSourceOptionsValidatorinsrc/Core/ZB.MOM.WW.OtOpcUa.Cluster/; registered viaAddValidatedOptionsinServiceCollectionExtensions.AddOtOpcUaCluster(:32). - Node discovery:
CentralCommunicationActor.cs:198-219already readsClusterNodes(enabled, non-maintenance) selectingNodeId, Host, AkkaPort— extend the same query shape toGrpcPortfor telemetry dial targets.ClusterNode.GrpcPort(nullable) already exists (Entities/ClusterNode.cs:46) explicitly for "the Phase 5 telemetry stream." - Central sinks (the untouched seam): registered in
HubServiceCollectionExtensions.AddOtOpcUaDriverStatusServices(:29-35); DPS bridges spawned inWithOtOpcUaSignalRBridges(:53-83). - ScadaBridge reference to mirror:
SiteStreamGrpcServer.cs(relay-actor + boundedDropOldestchannel + lifecycle/cleanup + concurrency cap + max-stream-lifetime),SiteStreamGrpcClient.cs/SiteStreamGrpcClientFactory.cs(channel cache, keepalive),SiteAlarmAggregatorActor.cs(generation-stamped, budget-limited, self-healing reconnect),ProtoContractTests.cs(reflection-over-oneof contract lock).
Tasks
Task 0: Telemetry proto contract + codegen + contract-lock test
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj(add the<Protobuf>item next to line 29) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Protos/TelemetryProtoContractTests.cs(create; match the existing Commons.Tests project layout — if there is no Commons.Tests project, add the test to the nearest existing Core test project that already references generated Commons types and note it)
Step 1: Write telemetry.proto. Package telemetry.v1, csharp_namespace = ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1. One server-streaming RPC + a oneof envelope with the four event kinds. Every enum carries a *_UNSPECIFIED = 0 zero value; every field comment marks additive intent.
syntax = "proto3";
package telemetry.v1;
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1";
import "google/protobuf/timestamp.proto";
// Central dials each driver node and opens Subscribe; the node streams its own live telemetry.
// Additive-only field evolution: never renumber/reuse a tag; a pre-field peer must read a new
// field's proto3 default correctly. Locked by TelemetryProtoContractTests.
service TelemetryStreamService {
rpc Subscribe(TelemetryStreamRequest) returns (stream TelemetryEvent);
}
message TelemetryStreamRequest {
string correlation_id = 1; // safe Akka path element; becomes the relay actor name on the node
}
message TelemetryEvent {
string correlation_id = 1;
oneof event {
AlarmTransition alarm_transition = 2; // <- DPS topic "alerts"
ScriptLog script_log = 3; // <- DPS topic "script-logs"
DriverHealth driver_health = 4; // <- DPS topic "driver-health"
DriverResilienceStatus driver_resilience = 5; // <- DPS topic "driver-resilience-status"
}
}
// The four event bodies below mirror the C# records field-for-field. Encode the domain records'
// enums as proto enums (UNSPECIFIED=0), and DateTimes as google.protobuf.Timestamp.
message AlarmTransition { /* fields mirroring Commons/Messages/Alerts/AlarmTransitionEvent.cs */ }
message ScriptLog { /* fields mirroring Commons/Messages/Logging/ScriptLogEntry.cs */ }
message DriverHealth { /* fields mirroring Commons/Messages/Drivers/DriverHealthChanged.cs */ }
message DriverResilienceStatus{ /* fields mirroring Commons/Messages/Drivers/DriverResilienceStatusChanged.cs */ }
The implementer MUST open each of the four domain records (paths in the scope table above) and
transcribe every field into the corresponding message with a stable tag number, choosing
Timestamp/Int32/string/bool/enum as the type dictates. Where a record enum exists, define a
matching proto enum with _UNSPECIFIED = 0. This is the contract — get it complete, because the
mapping tasks (7) and the lock test below depend on it.
Step 2: Register codegen. In the .csproj, next to the existing deployment_artifact.proto item:
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both" />
Step 3: Write the contract-lock test (fails first). Mirror ScadaBridge ProtoContractTests.AllOneofVariants_HaveConversionHandlers: reflect over Enum.GetValues<TelemetryEvent.EventOneofCase>() minus None, assert count- and membership-equality against a hand-maintained HandledCases array that Task 7's converter will also key off. This fails until the proto compiles and the array is filled.
[Fact]
public void EveryOneofVariant_IsAccountedFor()
{
var variants = Enum.GetValues<TelemetryEvent.EventOneofCase>()
.Where(c => c != TelemetryEvent.EventOneofCase.None).ToArray();
variants.ShouldBe(TelemetryProtoContract.HandledCases, ignoreOrder: true);
}
Add a tiny TelemetryProtoContract.HandledCases constant array in Commons (the single source both
this test and the Task-7 converter reference) listing the four cases.
Step 4: dotnet build ZB.MOM.WW.OtOpcUa.slnx — expect the generated types to appear; test goes green.
Step 5: Commit. feat(mesh-phase5): telemetry.proto contract + oneof + contract-lock test
Task 1: Telemetry options (serve + dial) + validator + registration
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 0, Task 2
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Cluster/TelemetryOptions.cs - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs(register inAddOtOpcUaCluster, ~lines 40-49 block) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/TelemetryOptionsValidatorTests.cs
Step 1: Define two options classes and one validator, mirroring ConfigSourceOptions/ConfigServeOptions + ConfigSourceOptionsValidator (which reads roles from IConfiguration, NOT IClusterRoleInfo).
public sealed class TelemetryOptions // section "Telemetry" (serve side, node)
{
public const string SectionName = "Telemetry";
public string Mode { get; set; } = "Dps"; // Dps | Grpc (central-ingest selector; harmless on node)
public int GrpcListenPort { get; set; } // 0 = disabled; driver node's telemetry h2c port
public string ApiKey { get; set; } = ""; // shared node key; supply via ${secret:}/env
}
public sealed class TelemetryDialOptions // section "TelemetryDial" (central)
{
public const string SectionName = "TelemetryDial";
public string Mode { get; set; } = "Dps"; // Dps | Grpc
public string ApiKey { get; set; } = ""; // must equal the nodes' Telemetry:ApiKey
public int ContactRefreshSeconds { get; set; } = 60;
public int CallTimeoutSeconds { get; set; } = 30;
}
Step 2: Validator rules (TelemetryOptionsValidator : IValidateOptions<TelemetryOptions>, ctor-inject IConfiguration, read Cluster:Roles like ConfigSourceOptionsValidator:98):
Modemust beDpsorGrpc(case-insensitive) — else Fail.- A driver-role node with
Mode=Grpcmust haveGrpcListenPort > 0— else Fail ("nothing to serve"). Mode=Grpcwith a non-empty role set must have a non-emptyApiKey(fail-closed: refuse to host an un-keyed telemetry surface) — else Fail.- Add a sibling
TelemetryDialOptionsValidator:Modein {Dps,Grpc};Mode=Grpc⇒ApiKeynon-empty.
Step 3: Register via AddValidatedOptions<TelemetryOptions, TelemetryOptionsValidator>(configuration, TelemetryOptions.SectionName) and the dial equivalent, in AddOtOpcUaCluster.
Step 4: Tests — Grpc+driver+port0 fails; Grpc+empty-key fails; Dps passes with defaults; unknown Mode fails. Run dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests.
Step 5: Commit. feat(mesh-phase5): Telemetry/TelemetryDial options + fail-closed validators
Task 2: Node-local in-process telemetry hub
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 0, Task 1
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Telemetry/ITelemetryLocalHub.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Telemetry/TelemetryLocalHub.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/TelemetryLocalHubTests.cs
Design: a process-wide singleton carrying this node's own telemetry (never cluster-wide — that is what keeps it correct on both the current single mesh and the Phase-6 pair mesh; DPS would leak peers' events). It holds a domain-typed union (not proto) so the publish seams stay decoupled from generated types. Two responsibilities:
Emit(TelemetryItem)— fan the item to every currently-subscribed writer through bounded per-subscriber channels withFullMode = DropOldest(live view is lossy under backpressure by design; a slow central never blocks the node).- Snapshot replay for last-value channels. Keep a last-value cache for
DriverHealth(keyed by driver instance) andDriverResilienceStatus(keyed by(instance, host));alerts/script-logsare append logs and are NOT cached. OnSubscribe, first drain the cached snapshots to the new writer, then attach it for live deltas — the simplified equivalent of ScadaBridge's seed-then-stream, so a central reconnect immediately re-primes the driver-health/resilience stores.
public abstract record TelemetryItem
{
public sealed record Alarm(AlarmTransitionEvent E) : TelemetryItem;
public sealed record Script(ScriptLogEntry E) : TelemetryItem;
public sealed record Health(DriverHealthChanged E) : TelemetryItem;
public sealed record Resilience(DriverResilienceStatusChanged E) : TelemetryItem;
}
public interface ITelemetryLocalHub
{
void Emit(TelemetryItem item);
// Returns a reader that first yields cached snapshots, then live deltas; disposing the
// subscription detaches and completes the channel.
ITelemetrySubscription Subscribe(int boundedCapacity);
}
TelemetryLocalHub uses a ConcurrentDictionary<Guid, Channel<TelemetryItem>> of subscribers +
two ConcurrentDictionary snapshot caches. Emit updates the snapshot cache (for Health/Resilience)
then TryWrites to each subscriber (drop-oldest handled by the bounded channel). Subscribe snapshots
the caches into the new channel under a brief lock ordering guarantee (cache-then-attach) so no delta
is lost across the attach boundary.
Register as a singleton in the driver-role DI branch (Task 3 wires the producers; do the
AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>() here in the Runtime ServiceCollectionExtensions
hasDriver path, or wherever driver-role Runtime services register — locate and match).
Tests: Emit-before-Subscribe is not seen except via snapshot (Health/Resilience last-value IS seen; Alarm/Script are not); two subscribers each get their own copy; a full channel drops oldest not newest; dispose detaches. Run the Runtime.Tests subset.
Step 5: Commit. feat(mesh-phase5): node-local telemetry hub (snapshot-replay + drop-oldest fan-out)
Task 3: Tap the four publish seams into the hub
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (needs Task 2)
Files (all Modify) — add a hub Emit beside the existing DPS Publish, leaving DPS intact:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs:36(driver-health)src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverResilienceStatusPublisherService.cs:80(resilience)src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs:379+src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:1405(alerts — two producers)src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs:261+src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Scripting/DpsScriptLogPublisher.cs:46(script-logs — two producers)- Test: extend the nearest existing publisher tests, or add
tests/.../Telemetry/PublishSeamEmitsToHubTests.cs
Approach: inject ITelemetryLocalHub into each producer (constructor for the services/publishers;
for actors, pass via Props — match how each already receives dependencies). At each seam, immediately
after the existing Mediator.Tell(new Publish(...)), add _telemetryHub.Emit(new TelemetryItem.Xxx(msg)).
Do NOT remove or gate the DPS publish — it is the Dps-mode path and must remain unconditional so
the switch stays pure. The hub is a no-op sink until a client subscribes, so this is safe on every node
regardless of mode.
For actors constructed where a hub isn't readily resolvable, resolve the singleton once at spawn and
thread it through Props (do not DependencyResolver inside the actor per-message). Where a producer
is only present on driver-role nodes, the hub singleton is guaranteed registered (Task 2).
Tests: each producer, when driven, results in exactly one hub.Emit of the correct TelemetryItem
subtype carrying the same payload it published to DPS. Use a fake ITelemetryLocalHub capturing emits.
Step 5: Commit. feat(mesh-phase5): tap the 4 telemetry publish seams into the local hub (DPS intact)
Task 4: Node-side gRPC streaming service
Classification: high-risk Estimated implement time: ~5 min (mirror ScadaBridge closely) Parallelizable with: none (needs Task 0, Task 2)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryStreamGrpcService.cs(place besideDeploymentArtifactService.cs; alias the generated base to avoid the name collision, as that file does at its lines 9-10) - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMap.Node.cs(domain→proto mapping; the reverse of Task 7) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/TelemetryStreamGrpcServiceTests.cs
Design — port SiteStreamGrpcServer.RunSubscriptionStreamAsync (.cs:253-385) shape:
public sealed class TelemetryStreamGrpcService : GeneratedTelemetryBase
{
// ctor: ITelemetryLocalHub hub, ILogger, IOptions<TelemetryOptions> (for a GrpcMaxConcurrentStreams knob)
public override async Task Subscribe(TelemetryStreamRequest request,
IServerStreamWriter<TelemetryEvent> responseStream, ServerCallContext context)
{
// 1. Validate correlation_id is a safe id (it labels the subscription; reject empty/oversized).
// 2. Concurrency cap (default 100) -> throw RpcException(ResourceExhausted) when exceeded.
// 3. sub = _hub.Subscribe(boundedCapacity: 1000); // snapshot-then-live, DropOldest inside the hub
// 4. MaxStreamLifetime linked-CTS CancelAfter (default 4h) ORed with context.CancellationToken,
// so a zombie stream terminates even if h2c keepalive misses it.
// 5. await foreach (item in sub.Reader.ReadAllAsync(ct))
// await responseStream.WriteAsync(TelemetryProtoMap.ToProto(item, request.CorrelationId), ct);
// 6. finally: sub.Dispose(); balance any opened/closed gauge; swallow OperationCanceled as normal.
}
}
Key correctness points (from the ScadaBridge recon — do not skip):
- The bounded DropOldest channel lives in the hub (Task 2), so the service just pumps its reader — a slow/blocked central cannot back-pressure the node's actor threads.
- Wrap the whole body so a client disconnect (
RpcException/OperationCanceledException) exits cleanly and disposes the subscription; never let it bubble as a fault. - No readiness race: the hub is a plain singleton available at host build, so unlike ScadaBridge's
SetReady(ActorSystem)gate there is nothing to defer — but if the driver actor system isn't up yet the hub simply has no snapshots and no deltas, which is fine.
TelemetryProtoMap.ToProto: switch on TelemetryItem subtype → build the matching proto message,
wrap in TelemetryEvent { CorrelationId, <oneof> = ... }. Transcribe every field (the reverse of the
.proto transcription in Task 0). DateTimes → Timestamp.FromDateTime(utc).
Tests: in-memory — subscribe, emit each of the four item kinds into the hub, assert the service
writes the correct proto event with fields intact; a cancelled ServerCallContext ends the stream and
disposes; the concurrency cap throws ResourceExhausted past the limit. (Full two-host wire test is
covered by the Task-12 live gate + optionally a Task-8 boundary test.)
Step 5: Commit. feat(mesh-phase5): node-side TelemetryStreamService (hub -> server-streaming)
Task 5: Telemetry stream auth interceptor
Classification: small Estimated implement time: ~3 min Parallelizable with: none (needs Task 1)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/TelemetryStreamAuthInterceptor.cs(copyConfigServeAuthInterceptor.csverbatim, change the gated prefix + options source) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/TelemetryStreamAuthInterceptorTests.cs
Steps:
ServicePrefix = "/telemetry.v1.TelemetryStreamService/"; readIOptions<TelemetryOptions>.ApiKey.- Override all four handler kinds (especially ServerStreaming — that is the RPC shape here) so the
gate holds; non-matching paths pass through; empty key ⇒ throw (fail-closed);
FixedTimeEqualson the Bearer token; reject withPermissionDenied. - Keep exactly one public constructor (the ScadaBridge recon flagged that
Grpc.AspNetCoresilently stops authorizing an interceptor with >1 ctor — pin it with a reflection test).
Tests: right key passes; wrong key PermissionDenied; empty configured key rejects all; a call to a
different service path passes through.
Step 5: Commit. feat(mesh-phase5): fail-closed bearer interceptor for the telemetry stream
Task 6: Kestrel wiring + map the node telemetry server
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (needs Task 1, Task 4, Task 5)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(theAddGrpcblock 425-431, the dedicated-listener block 434-533, and the map block 577-580) - Test: none new (covered by Task 12 live gate); build must stay green
Steps — extend, do not rewrite, the existing block:
-
Interceptor registration (
Program.cs:425-431): inside theAddGrpc(o => …), addif (hasDriver) o.Interceptors.Add<TelemetryStreamAuthInterceptor>();(alongside the LocalDbSync one — both are driver-side, both path-scoped, harmless when their service is unmapped). -
Port resolution (near
:453-454):var telemetryListenPort = hasDriver ? builder.Configuration.GetValue<int>("Telemetry:GrpcListenPort") : 0;Add
|| telemetryListenPort > 0to the block guard at:455. -
Bind it inside the
ConfigureKestrelclosure (:517-526): capturevar telemetryPortToBind = telemetryListenPort;beside the other two, and addif (telemetryPortToBind > 0) kestrel.ListenAnyIP(telemetryPortToBind, o => o.Protocols = HttpProtocols.Http2);. -
HTTPS-refuse branch (
:500-511): settelemetryListenPort = 0;too, and add it to the log message (all dedicated listeners disable together when the host serves HTTPS). -
Map the service (after
:580):if (hasDriver && telemetryListenPort > 0) app.MapGrpcService<TelemetryStreamGrpcService>(); -
Update the block's header comment (434-452) to mention the third dedicated port.
Verify: dotnet build; a fused node with all three ports set binds all three exactly once (the
"re-bind existing surface once" invariant already holds — the new port is just one more
ListenAnyIP). A driver-only node with only Telemetry:GrpcListenPort set binds only that.
Step 5: Commit. feat(mesh-phase5): host the telemetry gRPC server on driver nodes (dedicated h2c port)
Task 7: Central per-node dialer client + proto→domain mapping
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (needs Task 0)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryStreamClient.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMap.Central.cs(proto→domain; reverse of Task 4; keyed offTelemetryProtoContract.HandledCases) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapTests.cs
Design — mirror SiteStreamGrpcClient.cs + GrpcDeploymentArtifactFetcher channel handling:
public sealed class TelemetryStreamClient : IDisposable
{
// ctor: string endpoint (http://host:port), string apiKey, IClientFactory seam for tests
// - GrpcChannel.ForAddress(endpoint) over http:// => prior-knowledge h2c; cache one channel per endpoint.
// - HTTP/2 keepalive: PingDelay 15s / Timeout 10s / Always (SocketsHttpHandler on the channel).
public async Task RunAsync(string correlationId, Action<TelemetryItem> onEvent,
Action<Exception> onError, CancellationToken ct)
{
// headers: authorization: Bearer {apiKey}
// using call = client.Subscribe(new TelemetryStreamRequest{CorrelationId=correlationId}, headers, ct)
// await foreach (evt in call.ResponseStream.ReadAllAsync(ct)) onEvent(TelemetryProtoMap.ToDomain(evt));
// RpcException(Cancelled) on shutdown => normal; anything else => onError (the reconnect trigger).
}
}
TelemetryProtoMap.ToDomain(TelemetryEvent) switches on evt.EventCase and reconstructs the domain
record (AlarmTransitionEvent / ScriptLogEntry / DriverHealthChanged / DriverResilienceStatusChanged).
It MUST cover exactly TelemetryProtoContract.HandledCases — a default: throw on an unknown case makes
"new event added but not mapped" a loud runtime failure, and the Task-0 lock test makes it a compile-time-ish
guard.
Tests: round-trip each domain record → TelemetryProtoMap.ToProto (Task 4) → ToDomain → assert
field-equality across all four kinds and all enum values (wire-fidelity, like ScadaBridge
ProtoRoundtripTests); an unmapped EventCase throws.
Step 5: Commit. feat(mesh-phase5): central telemetry dialer client + proto<->domain round-trip
Task 8: Central dial supervisor actor (discovery + reconnect + feed sinks)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (needs Task 1, Task 7)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryDialSupervisor.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryDialSupervisorTests.cs
Design — an admin-role actor holding one dialer per driver node, feeding the existing central sinks:
- Discovery: on
PreStartand everyTelemetryDialOptions.ContactRefreshSeconds(+ on an admin-change signal if one is already wired forCentralCommunicationActor), read enabled, non-maintenanceClusterNoderows selectingNodeId, Host, GrpcPort(extend theCentralCommunicationActor.cs:198-219query). A row withGrpcPort == nullis skipped with a Warning (the node exposes no telemetry port — honest, not an error). Buildhttp://{Host}:{GrpcPort}dial targets; add dialers for new nodes, stop dialers for removed ones. - Per-node dialer loop (mirror
SiteAlarmAggregatorActor.OpenGrpcStream/HandleGrpcError, simplified — one node per dialer, no NodeA/NodeB flip since eachClusterNoderow is its own node):- Open
TelemetryStreamClient.RunAsync;onEventposts eachTelemetryItemto the supervisor which routes to the sink (below);onErrorschedules a reconnect. - Reconnect: immediate first retry, then fixed
_reconnectDelay(5s) backoff. A monotonic generation stamp per node makes late errors from a superseded stream ignorable. The dialer does not die on repeated failure — it keeps retrying (observability, not data plane); log the first failure + every Nth. - A
correlationIdper (node, generation) — a safe id string, e.g.central-{NodeId}-{gen}.
- Open
- Feed the sinks (the untouched seam): inject the four singletons and route by item type:
Alarm→IInProcessBroadcaster<AlarmTransitionEvent>.Publish(e)Script→IInProcessBroadcaster<ScriptLogEntry>.Publish(e)Health→IDriverStatusSnapshotStore.Upsert(e)Resilience→IDriverResilienceStatusStore.Upsert(e)
- Connection indicator: drive the two broadcasters'
SetConnected(...)off aggregate stream health — connected when ≥1 node stream is up, disconnected when all are down — matching today's DPSSubscribeAck/PostStoppill semantics on/alertsand/script-log. (The two store-backed panels have no connection flag today; leave their per-row staleness as-is — noted for Task 10 docs.)
Registration is done in Task 9 (mode-gated). This task only builds + unit-tests the actor with a
fake TelemetryStreamClient factory and fake sinks.
Tests: discovery adds/removes dialers as the (fake) ClusterNode set changes; a null GrpcPort
row is skipped; an emitted item of each kind lands on the correct sink; a stream error triggers a
scheduled reconnect with an incremented generation; a late event from a superseded generation is
dropped; broadcaster IsConnected flips true on first up / false on all-down.
Step 5: Commit. feat(mesh-phase5): central telemetry dial supervisor (discover + reconnect + feed sinks)
Task 9: Wire the dark switch (central ingest source)
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (needs Task 8)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs(WithOtOpcUaSignalRBridges, lines 53-83) - Modify: the AdminUI Akka configurator call site that invokes
WithOtOpcUaSignalRBridges(locate; it's inside thehasAdminbranch per the method's own doc example) — pass or resolve the mode - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs
Steps:
- Inside
WithActors, resolveIOptions<TelemetryDialOptions>and readMode. Mode == Dps(default): spawn all five bridges exactly as today (unchanged behaviour).Mode == Grpc: spawn the fleet-status bridge only (out of Phase 5 scope, stays on DPS), and do NOT spawn the four telemetry DPS bridges (alert, script-log, driver-status, resilience). Instead spawnTelemetryDialSupervisor(Task 8), resolving the four sink singletons + theIDbContextFactory<OtOpcUaConfigDbContext>(admin nodes have ConfigDb) +IOptions<TelemetryDialOptions>.- The sinks (
AddOtOpcUaDriverStatusServices,:29-35) are registered identically in both modes — no change there; that is the whole point of the swap.
Guard rails:
- Keep the fleet-status bridge on DPS in both modes (deferred).
- A
Grpc-mode admin node still needs the sinks registered (it does — same call), so the AdminUI components resolve them regardless.
Tests: with Mode=Dps, the registry has the four telemetry bridge keys and no supervisor; with
Mode=Grpc, it has the supervisor and the fleet-status bridge but none of the four telemetry bridge
keys. (Use the Akka.Hosting ActorRegistry + a TestKit or the existing bridge-registration test
harness if one exists.)
Step 5: Commit. feat(mesh-phase5): dark-switch central telemetry ingest (Dps bridges | Grpc dialer)
Task 10: Docs — Telemetry section, supersede §6.3, program status
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 11 (disjoint files)
Files (Modify/Create):
- Create:
docs/Telemetry.md— the stream architecture (direction, dark switch, auth, the four channels, deferred three + why, reconnect story, per-panel connection indicator status). - Modify:
docs/Configuration.md— addTelemetry+TelemetryDialsections (keys table, theModedark switch, port + shared-key${secret:}guidance). - Modify:
docs/Redundancy.md— a short "Command/observability transport" cross-ref noting redundancy-state stays DPS (pair-local) and telemetry moved to gRPC. - Modify:
docs/plans/2026-07-21-per-cluster-mesh-design.md§6.3 — supersede note: "telemetry stream authenticated from day one (fail-closed bearer), superseding the earlier 'unauthenticated for now'; ScadaBridge closed the same gap identically." - Modify:
docs/plans/2026-07-22-per-cluster-mesh-program.md— Phase 5 section + Tracking row: record the four-channel scope, the three deferrals + rationale, the dark switch, auth-from-day-one. - Modify:
CLAUDE.md— a "Live telemetry transport (Telemetry/TelemetryDial)" section mirroring the MeshTransport/ConfigSource sections (defaultDps, node hosts server, central dials, four channels, deferred three, fail-closed key).
Step 5: Commit. docs(mesh-phase5): telemetry stream — Telemetry.md, config, supersede §6.3, program status
Task 11: Rig config — telemetry ports + keys + flip env
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 10 (disjoint files); needs Task 6 + Task 9 landed for the flip to mean anything
Files:
- Modify:
docker-dev/docker-compose.yml - Modify: any committed
appsettings.jsondefaults that must carry the (default-OFF) keys — verify none are needed sinceModedefaults toDpsand port defaults to 0.
Steps:
- Give every driver-role node (central-1/2 fused + the four site nodes)
Telemetry__GrpcListenPort(pick a non-colliding port — verify against 4053 Akka / 4055 ConfigServe / the LocalDb sync port; use e.g. 4056) andTelemetry__ApiKey: "telemetry-docker-dev-key"(committed dev-secret exception, likeconfigserve-docker-dev-key). - Give the central (admin) nodes
TelemetryDial__ApiKey: "telemetry-docker-dev-key"(matching). - Add each driver node's
GrpcPortto itsClusterNodeseed row (the rig's SQL seed / migration seed) so central discovers the telemetry endpoint — mirror howAkkaPortwas seeded in Phase 1. - Leave
Telemetry__Mode/TelemetryDial__Modeunset (⇒Dpsdefault). Document the flip in the compose header: setTelemetry__Mode=Grpcon the driver nodes andTelemetryDial__Mode=Grpcon the central nodes atdocker compose up, then recreate. lmxopcua-fix/rig note: this is the localdocker-devrig, not the shared driver-fixture host.
Step 5: Commit. chore(mesh-phase5): rig telemetry ports + shared key + ClusterNode.GrpcPort seed
Task 12: Live gate
Classification: high-risk (gate, not a code change — but the phase is not done until it passes) Estimated implement time: live rig run (not subagent wall-time) Parallelizable with: none (needs everything)
Files:
- Create:
docs/plans/2026-07-23-mesh-phase5-live-gate.md(record)
Procedure (on the local docker-dev rig, matching prior phases' gates):
- Build the Phase-5 image (
--platform linux/amd64build stage;--progress=plain) and recreate the rig in defaultDpsmode — confirm all six nodes up,/alerts,/script-log,/hostspanels live exactly as before (regression check: DPS path unchanged). - Flip: set
Telemetry__Mode=Grpcon all driver nodes andTelemetryDial__Mode=Grpcon the central nodes;docker compose up(recreate). - Exit gate leg A — panels green over gRPC: drive a scripted-alarm transition and a script-log emission and a driver health/resilience change; confirm
/alerts,/script-log, and the/hostsdriver table update live — with the four DPS telemetry bridges NOT spawned (grep the central logs to confirm the Grpc branch ran and the four bridge keys are absent). Confirm the/alerts+/script-logconnection pill reads "live". - Exit gate leg B — kill-and-reconnect:
docker killa site driver node (or drop its telemetry port), watch the central dialer log the error + retries and the pill flip to disconnected; restart the node and confirm the dialer reconnects, the driver-health/resilience stores re-prime from the node hub's snapshot replay, and the pill returns to live — every stream recovers. - Optionally confirm the auth gate: a dial with a wrong
TelemetryDial:ApiKeygetsPermissionDeniedand no panel data (a deliberate misconfig probe). - Record all legs + evidence in the gate doc; note any deviations (as Phases 1-4 gates did).
Exit gate (program): all AdminUI live panels green against the rig with the telemetry DPS bridges off; kill-and-reconnect of the central dialer recovers every stream.
Step: Commit the gate record; then finish per superpowers-extended-cc:finishing-a-development-branch
(merge feat/mesh-phase5 to master locally, push, update the scadaproj umbrella index, update memory).
Risks / watch-items carried into execution
- Single-mesh correctness of the node-local hub. The hub must carry only this node's events (Task 2) — never subscribe it to cluster DPS, or on the current single mesh every node would stream every other node's events and central would double-count. Snapshot replay + drop-oldest come from the hub, not the service.
- Do not gate/remove the DPS publish at the four seams (Task 3) — that is the
Dps-mode path and the pure-dark-switch guarantee. The hub is a no-op until a client connects. - Interceptor single-ctor pin (Task 5) —
Grpc.AspNetCoresilently disables an interceptor with1 public ctor.
- Kestrel "re-bind once" invariant (Task 6) — the third dedicated port is just one more
ListenAnyIP; the existing-surface re-bind still happens exactly once. Don't duplicate it. - Frame size is a non-issue — server-streaming telemetry events are small and never cross Akka remoting; the 128 KB ClusterClient frame trap does not apply to this path.
- Additive-only proto evolution — new event kinds/fields only; the Task-0 lock test + the
Task-7
default: throware the guard. Never renumber a tag. - Deferred-channel honesty — redundancy-state / fleet-status / deployment-acks are out by design, documented in Task 10; a future reviewer must not read "seven topics" in the program doc and think Phase 5 missed three.