87 KiB
Design + Implementation Plan — R3-04 One Role Authority
- Source reports:
archreview/01-core-composition.md§S-12 (:198-210) + §C-9 (:264-278) ·archreview/03-server-runtime.md§C5 (:131-133) (2026-08-19 round-3 re-review at12b0035a) - Prior designs consumed: none — both findings are new in round 3. The nearest ancestor is Gitea #532 (
RoleParser.ResolveEffectiveRoles+ theAddOtOpcUaClusterPostConfigure), whose remediation created the second authority these findings are about; the field incident it closed isdocs/deployments/wonder-app-vd03-makino-z-34184.md. - Plan verified against tree at: master
7c77a8c7(clean; code identical to12b0035a) — every anchor re-opened and re-checked 2026-08-20. - Scope: OVERALL prioritized action item #4 — "One role authority:
ResolveEffectiveRolesnormalizes + validates;Program.csand all four validators consume it; boot guard asserting Akka and Host role views agree" (01 S-12/C-9, 03 C5). Effort Small / Impact Medium-High. - Cross-cutting requirement (OVERALL theme 4, "One rule stated twice diverges"): the deliverable is not "fix two call sites" — it is that the role list is derived in exactly one place and every consumer inherits it, enforced by a guard test so the rule cannot be re-stated. The authoring-vs-deploy-gate arc (#535→#559) is the named precedent: one authority, enforced in the service, mirrored by the gate. Concrete pick here: a
EffectiveRolesvalue type produced by one factory, consumed byProgram.cs, the fourClustervalidators, the Akka options PostConfigure and every gossip-side predicate; plusRoleAuthorityGuardTests, a source-scanning guard modelled onCommandDisposalConventionsTests/StatusCodeParityTests, that fails when any production file outside the authority readsOTOPCUA_ROLES,Cluster:Roles, or a bare"admin"/"driver"role literal. - Cross-cutting requirement (OVERALL theme 2, "verification that cannot see its own blind spot"): the docker-dev rig sets both role sources, consistently, on all six nodes (
docker-compose.yml:298/352-354,:448/485-487,:564/606-607,:686/716-717,:782/803-804,:853/874-875). Neither finding can reproduce on the rig as configured. The live gate below therefore changes the rig (site-b-2 becomesCluster:Roles-only) so the divergence becomes observable — the same lesson 03/S14 taught aboutDisableLogin=true.
Verification summary
Both findings CONFIRMED at 7c77a8c7 — neither stale, neither partially fixed. Every report anchor is exact; no drift. Anchor status:
| Finding | Report anchor | Status at 7c77a8c7 |
|---|---|---|
| 01/S-12 = 03/C5 | Program.cs:61-63 (RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")) → hasAdmin/hasDriver) |
Exact. :61 resolve, :62 roles.Contains("admin"), :63 roles.Contains("driver") — bare string literals, not RoleParser.Admin/.Driver, a detail neither report names. The env-only array reaches four further decisions the reports do not enumerate: the per-role appsettings overlay filename (:80), the health-check registration (:564 AddOtOpcUaHealth(hasAdmin)), the three dedicated gRPC listener ports (:624-626), and the boot banner (:789-790). |
| 01/S-12 = 03/C5 | RoleParser.cs:115-125 / :115-119 (ResolveEffectiveRoles) |
Exact. Two-arg overload :115-119 (configured is { Count: > 0 } ⇒ verbatim copy; else Parse(envRoles)), one-arg convenience :124-125, RolesEnvVar const :128, Parse :133-152. |
| 01/S-12 = 03/C5 | Cluster/ServiceCollectionExtensions.cs PostConfigure |
Exact, now pinpointed: :51 — options => options.Roles = RoleParser.ResolveEffectiveRoles(options.Roles), applied inside AddOtOpcUaCluster (:34-51). The gossiped list is handed to Akka at :267 (Roles = options.Roles). |
| 01/S-12 = 03/C5 | RoleParser.cs:96-103 ("the exact knob the wonder-app-vd03 field fix hand-added") |
Exact — the <remarks> paragraph is :94-103. |
| 01/C-9 | SplitTopologyTransportValidator.cs:69-78 (the one validator that normalizes) |
Exact. Comment :69-72; configured read :73; ResolveEffectiveRoles :74; normalized = Array.ConvertAll(effective, r => r.Trim().ToLowerInvariant()) :76; classification :78, :89-90. |
| 01/C-9 | MeshTransportOptionsValidator.cs:76-79 |
Exact — ResolveEffectiveRoles(GetSection("Cluster:Roles").Get<string[]>()) :76-77; case-sensitive Array.IndexOf(roles, RoleParser.Driver) :78, .Admin :79. |
| 01/C-9 | TelemetryOptions.cs:124-126 (TelemetryOptionsValidator, class at :88) |
Exact — resolve :124-125, case-sensitive IndexOf :126. Note the sibling TelemetryDialOptionsValidator (:160) reads no roles at all. |
| 01/C-9 | ConfigSourceOptionsValidator.cs:101-104 |
Exact — resolve :101-102, case-sensitive IndexOf :103-104. |
| 01/C-9 | "the four Cluster fail-fast validators" | Correct as a count, but the brief's fourth candidate is wrong. AkkaClusterOptionsValidator (AkkaClusterOptionsValidator.cs:37) reads no roles — it validates self-first seed ordering only. The four role-reading validators are SplitTopologyTransportValidator, MeshTransportOptionsValidator, TelemetryOptionsValidator, ConfigSourceOptionsValidator; all four are registered in Cluster/ServiceCollectionExtensions.cs:56/63/70/80. |
| 01/C-9 | "only the env fallback is lowercased/validated by Parse" |
Exact and load-bearing. Parse :137-141 lower-cases + trims + dedupes and :143-149 throws ArgumentException on an unknown role. The configured path (:117) does none of that: Cluster:Roles: ["Drivr"] is accepted silently, gossiped verbatim, and matches nothing. |
The blast radius is materially larger than either finding states, and the plan is sized to the larger one. 01/C-9 describes the case hazard as "skips every driver-scoped boot-time rule". That is true and it is the smaller half. The same verbatim spelling is gossiped to Akka (ServiceCollectionExtensions.cs:267), and six production predicates match gossiped roles ordinally against the lowercase constant: RedundancyStateActor.cs:146/193 (Primary election), ManualFailoverService.cs:125, ClusterNodeAddressReconcilerActor.cs:185, DriverHostActor.cs:1612 (the R2-04 primary-gate member count), ScriptedAlarmHostActor.cs:520, and Fleet.razor:149. A pair configured Cluster:Roles: ["Driver"] therefore elects no Primary at all (SelectOldestUpMemberOfRole(members, "driver") matches nothing ⇒ every node Detached ⇒ the R2-04 data-plane gate denies every write, the alarm S&F queue never drains, ServiceLevel never rises) while /hosts cheerfully renders the role chip Driver. Akka's own ClusterSingletonOptions.Role matching (ControlPlane/ServiceCollectionExtensions.cs:52 AdminRole = "admin", :247 ?? RoleParser.Driver) is likewise ordinal and outside this repo's control — which is why the fix must normalize the gossiped plain-role strings, not only the local predicates.
Supporting facts verified: RoleParser.IsClusterRole is already case-insensitive (:37-40, Gitea #532) while ClusterIdFromRole (:53) deliberately preserves the operator's suffix spelling, with an XML <remarks> (:44-52) explicitly warning that normalizing one side "would silently rename the Akka role of a running deployment"; every in-repo ClusterId comparison is OrdinalIgnoreCase (ConfigDbApiKeyStore.cs:144-152, DeploymentArtifact.ParseDataPlaneAuthority, AclScopeRef); ClusterRoleInfo builds _localRoles as an OrdinalIgnoreCase HashSet (ClusterRoleInfo.cs:45) so HasRole is already case-insensitive but LocalRoles still exposes the verbatim strings; none of the four shipped appsettings*.json overlays carries a Cluster section (keys are Serilog + Security only), so the "the role overlay may not set Cluster:Roles" invariant this plan states costs zero migration; Install-Services.ps1:177-182 writes only OTOPCUA_ROLES to the Windows service environment ($normalisedRoles is trimmed + sorted + deduped but not lower-cased) and docs/deployments/wonder-app-vd03-makino-z-34184.md:46-60 records the field fix that hand-added Cluster__Roles__0/1 beside it — so prod runs both sources and OTOPCUA_ROLES must keep working; the repo has an established source-scanning convention-test idiom (CommandDisposalConventionsTests.cs:54-66, walking up from AppContext.BaseDirectory to the ZB.MOM.WW.OtOpcUa.slnx root) and an established reflection-census idiom (StatusCodeParityTests, PageAuthorizationGuardTests, DeferredSinkForwardingReflectionTests); EnvVarScope + the OTOPCUA_ROLES xUnit collection (tests/Core/…Cluster.Tests/EnvVarScope.cs:26-37) already serialize every test class that touches the process env var — new tests must join that collection.
Role-reading census (every production site, 7c77a8c7)
"Source" is what the site actually reads. "Normalizes?" is whether that site trims/case-folds before deciding. env = OTOPCUA_ROLES via RoleParser.Parse; cfg⇒env = ResolveEffectiveRoles (configured-first, env fallback); bound = AkkaClusterOptions.Roles (post-AddOtOpcUaCluster PostConfigure, i.e. cfg⇒env verbatim); gossip = Member.Roles off live Cluster.State.
| # | Site | Source | Normalizes? | What it decides |
|---|---|---|---|---|
| 1 | Host/Program.cs:61 |
env only | lower+validate (via Parse) |
the whole host role view |
| 2 | Host/Program.cs:62 roles.Contains("admin") |
env only | n/a (bare literal, ordinal) | hasAdmin |
| 3 | Host/Program.cs:63 roles.Contains("driver") |
env only | n/a (bare literal, ordinal) | hasDriver |
| 4 | Host/Program.cs:80 roleSuffix |
env only | no (ordinal sort of already-lowered) | which appsettings.{role}.json overlay loads |
| 5 | Host/Program.cs:158/172/374/414/435/477/482/520/586/590/596/730/747/755/780 |
env only (via 2/3) | n/a | every conditional registration + endpoint map |
| 6 | Host/Program.cs:564 AddOtOpcUaHealth(hasAdmin) |
env only | n/a | the ConfigDb health check |
| 7 | Host/Program.cs:624-626 |
env only | n/a | LocalDb-sync / ConfigServe / Telemetry listener ports |
| 8 | Host/Program.cs:789-790 banner |
env only | n/a | the operator-visible boot line |
| 9 | Cluster/ServiceCollectionExtensions.cs:51 PostConfigure |
cfg⇒env | no | AkkaClusterOptions.Roles — the authority for everything below |
| 10 | Cluster/ServiceCollectionExtensions.cs:267 Roles = options.Roles |
bound | no | what Akka gossips |
| 11 | Cluster/SplitTopologyTransportValidator.cs:73-76,89-90 |
cfg⇒env | yes (trim + lower, :76) |
split-topology transport fail-fast |
| 12 | Cluster/MeshTransportOptionsValidator.cs:76-79 |
cfg⇒env | no | GrpcPsk / CentralGrpcEndpoints fail-fast |
| 13 | Cluster/TelemetryOptions.cs:124-126 (TelemetryOptionsValidator) |
cfg⇒env | no | Telemetry ApiKey / listen-port fail-fast |
| 14 | Cluster/ConfigSourceOptionsValidator.cs:101-104 |
cfg⇒env | no | driver-only ⇒ FetchAndCache fail-fast |
| 15 | Cluster/ClusterRoleInfo.cs:45 _localRoles |
bound | yes for lookups (OrdinalIgnoreCase set); no trim; LocalRoles exposes verbatim |
HasRole, /fleet + /hosts role chips |
| 16 | Cluster/ClusterRoleInfo.cs:52 ClusterRolesIn |
bound | prefix case-insensitive; suffix verbatim | ClusterId / ClusterRole identity |
| 17 | Cluster/ClusterRoleInfo.cs:116/123/142/150 |
gossip | no | fleet-wide role→member index |
| 18 | ControlPlane/ServiceCollectionExtensions.cs:24,52 AdminRole = "admin" |
literal ⇒ Akka | n/a (Akka matches ordinally vs gossip) | which node hosts the 5 admin singletons |
| 19 | ControlPlane/ServiceCollectionExtensions.cs:246-247 |
bound | prefix case-insensitive; falls back to RoleParser.Driver |
redundancy-singleton cluster scope |
| 20 | ControlPlane/Redundancy/RedundancyStateActor.cs:146,193 |
gossip | no (Roles.Contains("driver")) |
Primary election |
| 21 | ControlPlane/Redundancy/ManualFailoverService.cs:125 |
gossip | no | manual failover target set |
| 22 | ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs:185 |
gossip | no | ClusterNode row reconciliation |
| 23 | Runtime/Drivers/DriverHostActor.cs:1612 |
gossip | no | R2-04 primary-gate driver member count |
| 24 | Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs:520 |
gossip | no | scripted-alarm emit gate member count |
| 25 | Runtime/ServiceCollectionExtensions.cs:323 GetService<IClusterRoleInfo>() |
bound (via 15/16) | inherits 15 | runtime actor cluster identity |
| 26 | ControlPlane/ServiceCollectionExtensions.cs:175 roleInfo.ClusterId/.ClusterRole |
bound (via 16) | inherits 16 | reconciler own-cluster scoping |
| 27 | Host/OpcUa/ConfigDbApiKeyStore.cs:83 ResolveClusterId |
bound | prefix case-insensitive; compared OrdinalIgnoreCase (:150) |
which cluster's ACL grants this node honours |
| 28 | Host/Program.cs:427 / :494 |
bound | inherits 27 / 19 | ACL cluster filter; redundancy-singleton scope |
| 29 | Host/Health/HealthEndpoints.cs:82 RolePreference = [Admin, Driver] |
literals ⇒ shared Health lib vs gossip | no | /health/active node preference |
| 30 | AdminUI/Components/Pages/Fleet.razor:149,159 MembersWithRole("driver") |
gossip | no (bare literal) | the /fleet "Akka drivers" column |
| 31 | AdminUI/Components/Pages/Hosts.razor:480,487 |
gossip | display only (OrdinalIgnoreCase sort) |
the /hosts role + leader-role chips |
| — | scripts/install/Install-Services.ps1:177-182 |
writes env only | trim + sort + dedupe, not lower | the prod Windows-service role knob |
| — | docker-dev/docker-compose.yml ×6 nodes |
writes both, consistently | n/a | the rig — and the reason neither finding reproduces there |
Census read: 31 production sites. 8 read the env var only (all in Program.cs, rows 1-8 — the whole of 01/S-12). 6 read cfg⇒env and only one of them normalizes (rows 11-14 — the whole of 01/C-9, plus the PostConfigure at row 9 and the Akka handoff at row 10 which normalize nothing). 11 read the bound options, mostly through ClusterRoleInfo, which is case-insensitive for lookups but verbatim for exposure. 6 match live gossip ordinally against a lowercase constant (rows 17, 20-24, 30) — the half neither finding names, and the half that turns a miscased Cluster:Roles into a no-Primary outage. 2 deployment surfaces set roles: prod (env only) and the rig (both).
How the two knobs relate today, exactly. There is no mapping of OTOPCUA_ROLES into Cluster:Roles in configuration — no AddEnvironmentVariables prefix trick, no alias. The two are read independently: Program.cs:61 reads the env var directly, and AddOtOpcUaCluster:51 post-configures the bound Cluster:Roles with ResolveEffectiveRoles, which falls back to the env var only when the bound array is empty. So the documented precedence — Cluster:Roles wins outright when non-empty, else OTOPCUA_ROLES (RoleParser.cs:104-109, docs/Configuration.md:126) — governs only the Akka side. The Host side has no precedence at all: it sees the env var and nothing else. The two views coincide on every node that sets both consistently (all six docker-dev nodes, and wonder-app-vd03 after its field fix) and diverge on exactly the shape #532 made first-class: Cluster:Roles set, OTOPCUA_ROLES unset. What operators are told: docs/Configuration.md:29 ("the roles themselves come from the OTOPCUA_ROLES env var"), :124 (Roles … "when empty, falls back to OTOPCUA_ROLES"), :126 (the two-knobs note, which already tells operators to prefer Cluster:Roles — i.e. the docs currently recommend the broken configuration), :449 (the env-var table), docs/ServiceHosting.md:19-27 (role gating is "Program.cs reads OTOPCUA_ROLES"), docs/Redundancy.md:211-231 (per-node identity example uses OTOPCUA_ROLES=admin,driver), docs/v2/Cluster.md:68 ("these two should stay in sync" — stated as an operator obligation, which is the whole defect). infra/ says nothing about roles.
Open design choices — resolved in this plan
- Precedence (keep two sources + reconcile · vs · deprecate
OTOPCUA_ROLES· vs · deprecateCluster:Roles): resolved to keep both, one documented precedence —Cluster:Rolesnon-empty wins outright, elseOTOPCUA_ROLES, else empty, i.e.ResolveEffectiveRoles's existing rule applied to both sides instead of one. DeprecatingOTOPCUA_ROLESis rejected outright:Install-Services.ps1:182writes it to the Windows service key, it is the only role knob on every non-field-fixed production install (memoryreference_servecli_wonder_app_vd03: 23 env vars on the service key), and it names the appsettings overlay — a deprecation would break every existing deployment for a cosmetic gain. DeprecatingCluster:Rolesis rejected because it is what Akka binds and what #532's own remediation made first-class. A Warning (not an error) when both are set and their normalized sets differ is added instead (choice 5). - Case normalization scope (normalize the whole gossiped list · vs · normalize only local predicates · vs · a two-layer value type): resolved to two layers in one type.
EffectiveRolescarries aGossipedlist (what Akka receives) in which plain roles are lower-cased but a cluster role keeps its ClusterId suffix verbatim (Cluster-SITE-A⇒cluster-SITE-A), and exposes case-insensitive predicates (HasAdmin/HasDriver/Has) for every decision. Rationale: lower-casing a plain role changes the gossiped string only for a node that is already broken (a node spelleddrivertoday is byte-identical after the change), while lower-casing a ClusterId suffix would renamecluster-MAIN/cluster-SITE-Aon a running fleet — precisely whatRoleParser.ClusterIdFromRole's<remarks>(:44-52) warns against, and a rolling upgrade of a pair would gossip two different role names mid-window and split it. Normalizing predicates only is insufficient because Akka's ownClusterSingletonOptions.Rolematch (rows 18/19) is ordinal and not ours to change. - Structural identity vs a runtime assert (the OVERALL action item's "boot guard asserting Akka and Host role views agree"): resolved to make them the same object, and the runtime assert is then not written.
Program.csresolvesEffectiveRolesonce and registers it as a singleton;AddOtOpcUaClustersetsoptions.Roles = effectiveRoles.Gossipedfrom that instance; the four validators take the instance by constructor injection. There is then no second derivation to disagree with, so an assert comparing two views would compare an object to itself — a test that can never fail is worse than no test, because it reads as coverage. What is kept is the one genuinely non-redundant residual check (choice 4). Note also that a literal "Akka gossip vs Host view" assert is not implementable where it would be wanted: gossiped roles do not exist until theActorSystemis built, and resolving anything ActorSystem-shaped during composition is the Phase-6 boot-StackOverflowtrap (CLAUDE.md;ControlPlane/ServiceCollectionExtensions.cs:239-248). - The residual divergence surface — the config-layer ordering trap.
Program.csmust resolve roles before it adds the per-role overlay (:80needs the role names to pick the filename), so the authority reads the pre-overlay configuration (baseappsettings.json+ env + args). If a role overlay — or a${secret:}expansion — ever setCluster:Roles, the post-overlay bind would differ from the instance every consumer holds. Resolved by stating the invariant ("Cluster:Rolesmay not be set in a role overlay") and enforcing it with a narrow fail-fast added to the existingAkkaClusterOptionsValidator: if the boundRoles(post-overlay) normalizes to a different set than the registeredEffectiveRoles, fail host start naming both. This is 5 lines, is not the redundant assert of choice 3 (it compares two genuinely different config snapshots), and is a no-op today — none of the four shipped overlays carries aClustersection. - Unknown / miscased role handling (silent · warn · fail): resolved to fail host start on an unknown role from either source, matching what
Parsealready does for the env path (:143-149) and closing the configured path's total absence of validation. The message follows the house validator style (MeshTransportOptionsValidator.cs:49-55): name the key, the offending value, the source it came from, and the allowed set. A miscased but known role does not fail — it normalizes silently, because failing it would brick every node an operator authored asDriverat exactly the moment they upgrade. Separately, a Warning fires when both sources are set and their normalized sets differ (theCluster:Roles-wins case an operator almost certainly did not intend). - Gossip-side predicates (leave them ordinal · vs · one case-insensitive helper): resolved to a shared
ClusterRoles.Has(IEnumerable<string> gossipedRoles, string role)helper (OrdinalIgnoreCase) adopted at all six ordinal gossip sites (rows 20-24, 30). With choice 2 in place a local node can no longer gossip a miscased plain role, so this is defence in depth for a peer running an older build — precisely the rolling-upgrade window choice 2 is designed to survive. Cheap, and it removes the last bare"driver"literals from production code so the guard test (choice 7) can be strict. - Guard style (reflection census · vs · source scan · vs · Roslyn analyzer): resolved to a source scan, because the thing being guarded is a textual read (
Environment.GetEnvironmentVariable("OTOPCUA_ROLES"),GetSection("Cluster:Roles"),"driver") that reflection cannot see — the same limitationStatusCodeParityTests' own XML doc calls out ("an inline literal is invisible to this guard"). The repo already has the idiom and the root-walk helper (CommandDisposalConventionsTests.cs:54-66). A Roslyn analyzer was considered and rejected as disproportionate for two forbidden strings. - Rig fidelity (leave docker-dev alone · vs · make one node
Cluster:Roles-only): resolved to change site-b-2 toCluster:Roles-only (drop itsOTOPCUA_ROLES). site-b is already the repo's designated A/B control node for the bootstrap guard, so it is the established place to pin a configuration variant, and site-b-2 is a driver-only node whose failure mode (deploy times out) is exactly what 01/S-12 predicts. Before the fix that node must fail the gate; after it, pass. Without this the gate is theatre — see theme 2.
Finding 1 — 01/S-12 = 03/C5: two role authorities can wire a node inconsistently (Medium, two independent reviewers)
Restatement
Program.cs:61-63 derives hasAdmin/hasDriver — the switches for the entire conditional host wiring, ~15 registration blocks, the health check, three gRPC listener ports and the appsettings overlay — solely from OTOPCUA_ROLES. The Akka cluster roles resolve configured-first through RoleParser.ResolveEffectiveRoles (RoleParser.cs:115-119), applied to the bound options by AddOtOpcUaCluster's PostConfigure (Cluster/ServiceCollectionExtensions.cs:51). A node configured only through Cluster:Roles — the shape #532's own remediation made first-class, and the shape docs/Configuration.md:126 currently recommends — joins the mesh advertising driver (and cluster-*), passes SplitTopologyTransportValidator and ConfigSourceOptionsValidator (which read effective roles), becomes an expected-ack member of every deployment via ConfigPublishCoordinator's enabled-ClusterNode-rows set, and times out every deployment forever because no driver stack was registered: no WithOtOpcUaRuntimeActors, no LocalDb, no OPC UA endpoint, no ConfigDb, and the deny-all NullOpcUaUserAuthenticator. It can even be the oldest role-carrying member for singleton placement without running the singleton manager. This is the exact inverse of the #532 incident, and nothing cross-checks the two sources.
Verification
Confirmed exact (see summary table). Precisely, at 7c77a8c7:
Program.cs:61—var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));, with the comment "Roles drive the entire conditional wiring below" one line above. Nothing else inProgram.csreadsCluster:Roles; the file's only other role reads are the boundAkkaClusterOptionsat:427and:494, both for cluster identity (ClusterId / singleton scope), never for wiring.:62-63—roles.Contains("admin")/roles.Contains("driver")against bare literals, notRoleParser.Admin/.Driver. (A second, independent brittleness: these two literals are invisible to any constant-based census.):80— the overlay filename isstring.Join('-', roles.OrderBy(...)), so aCluster:Roles-only node also loads no role overlay — a quiet second consequence neither report names.Cluster/ServiceCollectionExtensions.cs:34-51— the PostConfigure.:267handsoptions.Rolesto Akka'sClusterOptions.- Deploy-path consequence re-verified against CLAUDE.md's Phase-1 note:
ConfigPublishCoordinatorsources its expected-ack set from enabledClusterNoderows, andEnabled = 0is rejected byDraftValidator.ValidateClusterTopology; so a mis-wired node cannot even be excluded without theMaintenanceModehatch. "Times out every deploy" is fleet-scoped, not node-scoped. - Rig blindness re-verified: all six docker-dev nodes set
OTOPCUA_ROLESandCluster__Roles__*to the same values (:298/:352-354,:448/:485-487,:564/:606-607,:686/:716-717,:782/:803-804,:853/:874-875).
Root cause
Program.cs's role read predates the cluster options entirely and was never revisited when #532 introduced the fallback. #532 closed the env-only→no Akka roles direction by teaching the Akka side about the env var; it did not teach the Host side about Cluster:Roles, so the fix created a second authority rather than collapsing to one. The structural reason it stayed hidden is ordering: Program.cs needs roles at line 61, before the host is built and before any options bind, so reaching for Environment.GetEnvironmentVariable is the path of least resistance — and the configuration object that could answer the question (builder.Configuration, available from line 66) is created five lines later.
Proposed design
One authority, one object, consumed everywhere.
The type (src/Core/ZB.MOM.WW.OtOpcUa.Cluster/EffectiveRoles.cs, new):
/// <summary>The node's effective cluster roles — the SINGLE authority for "what is this node".
/// Resolved once, from one documented precedence, and consumed by BOTH the Host's conditional
/// wiring and the Akka role list, so the two cannot disagree (archreview 01/S-12 = 03/C5).</summary>
/// <remarks>
/// <para><b>Precedence:</b> <c>Cluster:Roles</c> when non-empty, else <c>OTOPCUA_ROLES</c>,
/// else empty. Unchanged from <see cref="RoleParser.ResolveEffectiveRoles"/> — what changed is
/// that <c>Program.cs</c> now obeys it too, instead of reading the environment directly.</para>
/// <para><b>Normalization is two-layer, on purpose.</b> <see cref="Gossiped"/> lower-cases plain
/// roles (Akka matches gossiped role names ORDINALLY, and every consumer compares against a
/// lowercase constant) but keeps a cluster role's ClusterId suffix VERBATIM — lower-casing that
/// would rename <c>cluster-SITE-A</c> on a running fleet, and a rolling pair upgrade would gossip
/// two different role names mid-window. Lower-casing a plain role changes the wire only for a
/// node that is already broken.</para>
/// </remarks>
public sealed class EffectiveRoles
{
public static EffectiveRoles Resolve(IConfiguration configuration); // the one factory
public static EffectiveRoles Resolve(IReadOnlyList<string>? configured, string? envRoles);
public IReadOnlyList<string> Gossiped { get; } // what AkkaClusterOptions.Roles becomes
public RoleSource Source { get; } // Configured | Environment | None
public bool HasAdmin { get; }
public bool HasDriver { get; }
public bool HasDev { get; }
public bool Has(string role); // OrdinalIgnoreCase
public string? ClusterRole { get; } // verbatim suffix
public string? ClusterId { get; }
}
Resolvevalidates both sources: every entry must beadmin/driver/dev(case-insensitive after trim) or a well-formedcluster-<non-empty>; otherwise it throws with the house message shape —"Cluster:Roles[1] = 'Drivr' is not a known role. Allowed: admin, driver, dev, or 'cluster-<ClusterId>'."— naming the key that supplied it.RoleParser.ResolveEffectiveRolesis retained as a thin delegation (=> EffectiveRoles.Resolve(configured, env).Gossiped.ToArray()) so nothing outside this plan's touched files breaks, and its XML doc points at the new authority.RoleParser.Parsestays as the env-string parserResolveuses.
Program.cs (:61-63):
// The SINGLE role authority: Cluster:Roles first, OTOPCUA_ROLES as the fallback (archreview 01/S-12).
// Resolved off builder.Configuration BEFORE the per-role overlay is added, because the overlay's
// FILENAME depends on the answer — which is why a role overlay may not itself set Cluster:Roles
// (AkkaClusterOptionsValidator fails start if one ever does).
var builder = WebApplication.CreateBuilder(args);
var effectiveRoles = EffectiveRoles.Resolve(builder.Configuration);
var hasAdmin = effectiveRoles.HasAdmin;
var hasDriver = effectiveRoles.HasDriver;
builder.Services.AddSingleton(effectiveRoles);
WebApplication.CreateBuilder(args) moves above the role read (it is currently at :66). :80's roleSuffix uses effectiveRoles.Gossiped; :789-790's banner gains source={Source} so the boot line states which knob won — the single cheapest operator-visible artifact of this change.
AddOtOpcUaCluster (Cluster/ServiceCollectionExtensions.cs:34-51): the PostConfigure consumes the registered instance rather than re-deriving —
services.TryAddSingleton(sp => EffectiveRoles.Resolve(sp.GetRequiredService<IConfiguration>()));
services.AddOptions<AkkaClusterOptions>().PostConfigure<EffectiveRoles>(
(options, roles) => options.Roles = [.. roles.Gossiped]);
TryAddSingleton keeps AddOtOpcUaCluster usable from harnesses that never ran Program.cs. This is the structural identity: AkkaClusterOptions.Roles is now by construction the same list hasDriver was computed from — there is no second derivation, hence no assert (resolved choice 3).
Residual guard (AkkaClusterOptionsValidator, existing, ValidateOnStart): if the bound Roles (post-overlay) normalizes to a different set than the injected EffectiveRoles.Gossiped, fail with "Cluster:Roles was changed by a configuration layer applied after role resolution (host view: [driver], bound: [admin,driver]). A per-role appsettings overlay may not set Cluster:Roles." No-op today (choice 4).
Alternatives considered:
- One-line fix:
Program.cs:61callsRoleParser.ResolveEffectiveRoles(builder.Configuration.GetSection("Cluster:Roles").Get<string[]>())— the fix both reports propose. Rejected as the whole answer: it closes S-12 and leaves C-9, leaves the two bare"admin"/"driver"literals, leaves the configured path unvalidated, and — critically — leaves two derivations of the same rule (Program's and the PostConfigure's), which is the exact disease theme 4 names. It is, however, the correct first commit (T3 below) and is sequenced as such. - Deprecate
OTOPCUA_ROLESwith a warning — rejected (choice 1). - Map
OTOPCUA_ROLESinto configuration asCluster:Rolesvia a customIConfigurationSource— attractive (it would make one key, not two), but rejected: the env var is comma-separated whileCluster:Rolesis an indexed array, so the mapping needs a bespoke provider whose ordering againstAddEnvironmentVariables/AddCommandLineis subtle, and it would silently change whatCluster:Rolesreads as in every diagnostic dump. A value type is simpler and states the precedence in code where a reader will find it. - A runtime boot guard comparing Akka gossip to the Host view — rejected (choice 3), replaced by the narrower config-layer guard.
Implementation steps
src/Core/ZB.MOM.WW.OtOpcUa.Cluster/EffectiveRoles.cs(new) — the type,Resolve×2, normalization, validation,RoleSourceenum.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs—ResolveEffectiveRoles×2 delegate toEffectiveRoles; XML docs re-point at the authority;Parseunchanged.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs—TryAddSingleton<EffectiveRoles>; PostConfigure at:51consumes it.src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs—:61-63rewrite (builder first),:80overlay,:789-790banner +source=; register the singleton.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptionsValidator.cs— the post-overlay set-equality guard.
Tests
tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/EffectiveRolesTests.cs(new; joins theOTOPCUA_ROLEScollection viaEnvVarScope): precedence matrix (configured-only / env-only / both-agree / both-differ / neither);Gossipednormalization (" Driver "⇒driver;Cluster-SITE-A⇒cluster-SITE-A— suffix case preserved, the falsification control for choice 2);HasDrivertrue for every casing; unknown role from either source throws naming the key;Sourcereports the winner.tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs(existing, 5 cases) — must stay green unchanged; it is the #532 regression net.tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ClusterRolesOnlyBootTests.cs(new, on theDriverOnlyNoConfigDbBootTestsdirect-host-build pattern — notWebApplicationFactory, perTwoNodeClusterHarness.cs:48): build the host withCluster:Roles = ["driver"]andOTOPCUA_ROLESunset, assert the driver stack is present (ILocalDbsingleton resolvable,IOpcUaUserAuthenticatorisApiKeyOpcUaUserAuthenticatornotNullOpcUaUserAuthenticator, the runtime actors registered) and thatIOptions<AkkaClusterOptions>.Value.Rolesequals["driver"]. Negative control: the same test against the pre-fix build must FAIL — record that it does.AkkaClusterOptionsValidatorTests— one case: a post-role-resolution config layer settingCluster:Rolesfails start; the no-overlay case passes.
Effort + Risk
Effort: S. Risk: medium-high — this changes what every node wires and what it gossips. The change is behaviour-preserving for every node that sets both sources consistently (all six rig nodes, every current prod install) and behaviour-correcting for the two broken shapes. The residual risks are (a) the WebApplication.CreateBuilder move ahead of the role read — mechanical but it reorders the first ten lines of the composition root, and (b) a node whose Cluster:Roles and OTOPCUA_ROLES genuinely differ today will now wire from Cluster:Roles, which is a real behaviour change the Warning in choice 5 is there to announce. Land T1-T3 behind the full Cluster.Tests + Host.Tests suites, and do not merge without the live gate (T13) — a role-wiring regression is a total node outage and the rig is the only place it shows.
Finding 2 — 01/C-9: role normalization is stated in one validator of four (Medium)
Restatement
SplitTopologyTransportValidator normalizes roles (trim + ToLowerInvariant, :76) and its comment names the exact hazard: "the Cluster:Roles bind path is verbatim (no lowercasing)… so 'Driver' / 'Admin' cannot slip past the ordinal checks" (:69-72). Its three siblings do the opposite: MeshTransportOptionsValidator.cs:78-79, TelemetryOptions.cs:126, and ConfigSourceOptionsValidator.cs:103-104 all run case-sensitive Array.IndexOf(roles, RoleParser.Driver) over ResolveEffectiveRoles, which returns configured roles verbatim (RoleParser.cs:117 — only the env fallback is lowercased/validated by Parse). A node configured Cluster:Roles: ["Driver"] silently skips every driver-scoped boot-time rule (GrpcPsk-required, CentralGrpcEndpoints-required, FetchAndCache-required) and fails later at runtime with precisely the absence-class failures these validators exist to fail fast on. Nothing validates unknown or miscased plain role names on the configured path at all.
Verification
Confirmed exact (see summary table), and the report under-states the consequence. The same verbatim string is gossiped (Cluster/ServiceCollectionExtensions.cs:267), and six production predicates match gossip ordinally against the lowercase constant — census rows 17, 20-24, 30. Concretely, a pair configured Cluster:Roles: ["Driver", "cluster-SITE-A"]:
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "driver")(:146) matches no member ⇒ no Primary is ever elected ⇒IsDriverPrimaryfalse on both nodes ⇒ (R2-04) every operator write is denied"not primary", alarm acks are dropped, the alerts fan-out never publishes, and the LocalDb alarm store-and-forward queue never drains (HistorianDrainState.NotPrimaryon both).ServiceLevelnever rises.DriverHostActor.cs:1612/ScriptedAlarmHostActor.cs:520count 0 driver members, so the R2-04 primary gate takes its single-node "allow" branch on a genuine 2-node pair — the exact dual-primary window R2-04 exists to close, silently re-opened by a spelling.ClusterNodeAddressReconcilerActor.cs:185sees no driver members ⇒ Error-logsEnabledRowNotInClusterfor every row, forever./hosts(Hosts.razor:487) renders the chipDriverand/fleet(Fleet.razor:149) shows zero Akka drivers — an operator sees a healthy-looking node in a fleet that reports none.
Also verified: AkkaClusterOptionsValidator — the fourth validator the task brief guessed at — reads no roles at all; the four are as listed above. And TelemetryDialOptionsValidator (TelemetryOptions.cs:160) reads none, so the central/dial side is unaffected.
Root cause
Two derivations of one rule, at two different times. SplitTopologyTransportValidator landed with Phase 6 (344b0d33) and normalized because its author reasoned about the two role sources explicitly; the other three copied the ResolveEffectiveRoles + Array.IndexOf shape without the normalization, which was the newer and less obvious half. Nothing structural connects the four — each re-derives "is this a driver node" from a raw array — so the correct spelling in one file is invisible to the next author. The deeper cause is the same as Finding 1's: normalization lives at the consumers instead of at the authority.
Proposed design
Delete the derivation from all four validators; inject the authority. Each validator's ctor takes EffectiveRoles instead of (or alongside) IConfiguration, and the four role lines collapse to roles.HasDriver / roles.HasAdmin. SplitTopologyTransportValidator additionally uses roles.ClusterRole / roles.ClusterId in place of its private FindIndex/ClusterIdFromRole pair — its message keeps the operator's original spelling because ClusterId preserves the suffix verbatim by design (choice 2). Its :59-72 comment block, which is now the history of a rule the authority states, shrinks to a one-line pointer.
Gossip-side defence in depth (choice 6). Add to RoleParser:
/// <summary>True when a GOSSIPED role list carries <paramref name="role"/>, compared
/// case-insensitively. Akka compares role names ordinally and a PEER may be running an older
/// build that gossips a verbatim 'Driver' (archreview 01/C-9), so every predicate over
/// Member.Roles goes through here rather than List.Contains.</summary>
public static bool GossipedRolesHave(IEnumerable<string>? gossipedRoles, string role);
Adopted at RedundancyStateActor.cs:193, ManualFailoverService.cs:125, ClusterNodeAddressReconcilerActor.cs:185, DriverHostActor.cs:1612, ScriptedAlarmHostActor.cs:520, Fleet.razor:149, and SelectOldestUpMemberOfRole (RedundancyStateActor.cs:146). ClusterRoleInfo's gossip index (:116/123/142/150) becomes an OrdinalIgnoreCase-keyed dictionary.
The guard that stops it recurring (choice 7). New tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleAuthorityGuardTests.cs, scanning src/**/*.cs + src/**/*.razor from the solution root (the CommandDisposalConventionsTests.cs:54-66 root-walk):
- No file except
EffectiveRoles.csandRoleParser.cscontainsOTOPCUA_ROLES(outside a comment or XML doc). - No file except
EffectiveRoles.cscontains"Cluster:Roles"as a configuration key (outside a comment / an error-message string literal — the scan strips//and///lines and allows occurrences inside a string that also contains a space, which is how the validators' operator messages read). - No file except
RoleParser.csdeclares or compares a bare"admin"/"driver"/"dev"role literal — the constants exist for this. This is the check that catchesProgram.cs:62-63andFleet.razor:149today. - Each rule reports every offending
file:line, not the first, and the XML doc states the escape hatch (add the file to the small, named allow-list with a reason) so the guard is maintainable rather than deleted at the first inconvenience.
Alternatives considered:
- Normalize inside
ResolveEffectiveRolesand leave the four validators alone — the fix 01/C-9 itself proposes. Rejected as insufficient on its own: it fixes the four validators (they inherit) but does nothing for the six ordinal gossip predicates, which are the half with the outage-grade consequence, and it leaves four re-derivations in place for the next rule to diverge on. It is, however, subsumed — the authority does normalize; the validators are converted anyway so the derivation stops existing. - Make every gossip predicate case-insensitive and skip normalizing the gossiped list — rejected: Akka's own singleton
Rolematching (rows 18/19) is ordinal and not ours to intercept, so a miscased node would still fail to host its singletons. - A Roslyn analyzer forbidding the literals — rejected as disproportionate (choice 7); the repo's analyzer project exists but the payload here is two strings.
StringComparer.OrdinalIgnoreCaseon the raw arrays at each of the four validators — rejected: that is the rule stated four more times, which is the finding.
Implementation steps
src/Core/ZB.MOM.WW.OtOpcUa.Cluster/SplitTopologyTransportValidator.cs— ctor takesEffectiveRoles;:59-90collapses; comment shrinks to a pointer.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/MeshTransportOptionsValidator.cs— same,:71-79.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/TelemetryOptions.cs(TelemetryOptionsValidator) — same,:119-126.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs— same,:95-104.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs—GossipedRolesHave.RedundancyStateActor.cs:146,193·ManualFailoverService.cs:125·ClusterNodeAddressReconcilerActor.cs:185·DriverHostActor.cs:1612·ScriptedAlarmHostActor.cs:520·AdminUI/Components/Pages/Fleet.razor:149,159·ClusterRoleInfo.cs:116-150— adopt it.src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs:56/63/70/80— the fourAddValidatedOptionsregistrations resolve the new ctor dependency (no signature change at the extension level; the validators are DI-constructed).
Tests
tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleCaseVariantValidatorTests.cs(new): a[Theory]over"Driver"," driver ","DRIVER","Admin"," cluster-SITE-A "× all four validators, asserting each fires the same failure it fires for the canonical spelling. The load-bearing case is the failure, not the pass: each variant must produce aValidateOptionsResult.Failnaming the same key, because a validator that never runs its driver branch also "passes". Add the mirrored canonical-spelling case as the control.tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleAuthorityGuardTests.cs(new, choice 7) — and it must be shown to fail on the pre-fix tree (Program.cs:62-63andFleet.razor:149are live violations today); record the pre-fix offender list in the commit body.tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/GossipedRoleMatchTests.cs(new):GossipedRolesHave(["Driver"], "driver")true;(["cluster-SITE-A"], "cluster-site-a")true; null/empty false.tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests— oneRedundancyStateActorcase where the member gossips"Driver"and a Primary is still elected (the outage this finding actually causes; a pure-unit test over the selection helper is sufficient and deterministic).- Existing suites stay green unchanged:
SplitTopologyTransportValidatorTests(16 cases incl. the twoOTOPCUA_ROLES-source cases at:238/:258),MeshTransportOptionsValidatorTests(incl.:308/:329),ConfigSourceOptionsValidatorTests,RoleParserTests,AkkaClusterOptionsRoleFallbackTests. Their construction changes fromnew XValidator(config)tonew XValidator(EffectiveRoles.Resolve(config))— mechanical, and it keeps them testing the validator rather than the resolution.
Effort + Risk
Effort: S-M (the four validators are ~4 lines each; the gossip-predicate sweep is 8 files; the guard test is the bulk). Risk: low-medium. The validators' behaviour is strictly widened (they now fire on inputs they used to skip), which can only turn a silent misconfiguration into a loud one — but that means a node an operator authored as Cluster:Roles: ["Driver"] will now refuse to start if it lacks its driver-scoped keys, where before it booted and misbehaved. That is the intent, and it is exactly the kind of change the live gate must exercise before merge. The gossip-side helper is behaviour-preserving for every correctly-spelled fleet.
Docs to update
| File | Change |
|---|---|
docs/Configuration.md:29 |
"The roles themselves come from the OTOPCUA_ROLES env var" ⇒ "…from Cluster:Roles, falling back to OTOPCUA_ROLES" (the overlay is now chosen from the effective roles). |
docs/Configuration.md:124 (Cluster:Roles row) |
Add: values are trimmed and (for plain roles) lower-cased; a cluster role's ClusterId keeps its spelling; an unknown role fails host start. |
docs/Configuration.md:126 (the two-knobs note) |
Rewrite. It currently says the two knobs drive different things ("OTOPCUA_ROLES drives all of Program.cs's conditional wiring; Cluster:Roles becomes the gossiped Akka roles") — that description is the defect. Replace with: one authority, one precedence, both consumers; the boot banner names the winning source; setting both to different values logs a Warning; a role overlay may not set Cluster:Roles. Keep the #532 history as a short parenthetical. |
docs/Configuration.md:449 (env-var table) |
OTOPCUA_ROLES row: read by EffectiveRoles.Resolve only, as the fallback when Cluster:Roles is empty. |
docs/ServiceHosting.md:19-27 |
"Program.cs reads OTOPCUA_ROLES" ⇒ resolves EffectiveRoles; the role table and the "Single-node dev / Production" line get the precedence sentence. |
docs/Redundancy.md:211-231 |
The per-node identity example shows OTOPCUA_ROLES=admin,driver; add the Cluster__Roles__0/1 equivalent and state that either works, both together must agree. |
docs/v2/Cluster.md:68 |
Delete "these two should stay in sync" — the operator obligation this plan removes. |
scripts/install/Install-Services.ps1 |
Doc-comment only: -Roles writes OTOPCUA_ROLES, which is now the documented fallback rather than the primary knob; no behaviour change (choice 1 — do not break it). |
CLAUDE.md |
One paragraph under the cluster/redundancy material: the role authority, the precedence, the guard test, and the "a role overlay may not set Cluster:Roles" invariant. |
Task breakdown
Branch: one branch fix/archreview-r3-04-one-role-authority off master. Ordering is genuinely sequential for T1→T4 (each consumes the previous type), then the sweep and guards fan out. Verification bar: every task has a deterministic unit guard; T3, T4 and T9 additionally carry falsification controls (a test that must FAIL on the pre-fix tree); T13 is the merge-blocking live gate. Follow TDD: write the failing test, see it FAIL, implement, see it PASS.
T1 — EffectiveRoles value type: one factory, one precedence, two-layer normalization, both-source validation
Classification: HIGH-RISK (defines the node's identity; every downstream decision inherits it)
Estimated implement time: 10 min
Parallelizable with: T8 (guard-test scaffolding only); NOT T2-T7
Files: src/Core/ZB.MOM.WW.OtOpcUa.Cluster/EffectiveRoles.cs (new), src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs, tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/EffectiveRolesTests.cs (new)
- Write
EffectiveRolesTestsper Finding 1's Tests list — precedence matrix,Gossipednormalization including thecluster-SITE-Asuffix-preservation control,Has*case-insensitivity, unknown-role-throws from both sources,Source. Join theOTOPCUA_ROLESxUnit collection (EnvVarScope). dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests --filter "FullyQualifiedName~EffectiveRolesTests"→ FAIL (no such type).- Implement
EffectiveRoles+RoleSource; makeRoleParser.ResolveEffectiveRoles(both overloads) delegate to it; leaveParseas the env-string parser. - Filter → PASS; then the full project:
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests→ PASS (RoleParserTests+AkkaClusterOptionsRoleFallbackTestsare the #532 regression net and must not need edits). - Commit:
feat(cluster): EffectiveRoles — one role authority with one precedence + two-layer normalization (archreview 01/S-12, 01/C-9).
T2 — AddOtOpcUaCluster post-configures from the registered instance (structural identity)
Classification: high-risk (changes what the node gossips)
Estimated implement time: 5 min
Parallelizable with: T8; after T1
Files: src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs, tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsRoleFallbackTests.cs
- Add a case: with
Cluster:Roles = ["Driver", "Cluster-SITE-A"], the boundAkkaClusterOptions.Rolesis["driver", "cluster-SITE-A"](plain lowered, suffix preserved) and is reference-equal in content to the registeredEffectiveRoles.Gossiped. Run → FAIL. - Implement:
TryAddSingleton<EffectiveRoles>+PostConfigure<EffectiveRoles>replacing:51. - Filter + full
Cluster.Tests→ PASS. Commit:fix(cluster): AkkaClusterOptions.Roles is the EffectiveRoles instance, not a second derivation.
T3 — Program.cs consumes the authority (closes 01/S-12 = 03/C5)
Classification: HIGH-RISK (every conditional registration in the composition root)
Estimated implement time: 10 min
Parallelizable with: nothing; after T2
Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
- Regression gate BEFORE editing:
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests→ record green. - Implement: move
WebApplication.CreateBuilder(args)above the role read;EffectiveRoles.Resolve(builder.Configuration);hasAdmin/hasDriverfrom it; register the singleton;:80overlay fromGossiped;:789-790banner gainssource=. dotnet build ZB.MOM.WW.OtOpcUa.slnx→ PASS;dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests→ PASS unchanged.- Commit:
fix(host): Program.cs derives roles from the one authority, not OTOPCUA_ROLES alone (archreview 01/S-12 = 03/C5).
T4 — The four role-reading validators consume the authority (closes 01/C-9)
Classification: medium-risk (fail-fast rules now fire on inputs they used to skip)
Estimated implement time: 10 min
Parallelizable with: T5, T6, T7; after T2
Files: SplitTopologyTransportValidator.cs, MeshTransportOptionsValidator.cs, TelemetryOptions.cs, ConfigSourceOptionsValidator.cs, Cluster/ServiceCollectionExtensions.cs, tests/Core/…Cluster.Tests/RoleCaseVariantValidatorTests.cs (new) + the four existing validator suites
- Write
RoleCaseVariantValidatorTests(Theory: 5 spellings × 4 validators, each asserting the same Fail as the canonical spelling, plus the canonical control). Run → FAIL for three of the four (SplitTopology already normalizes — that asymmetry is the finding, and it makes the first run a built-in falsification control). - Implement: each validator's ctor takes
EffectiveRoles; theResolveEffectiveRoles+Array.IndexOfpairs collapse toroles.HasDriver/roles.HasAdmin;SplitTopologyTransportValidatorusesroles.ClusterRole/.ClusterId; update the four existing test suites' construction. - Filter → PASS; full
Cluster.Tests→ PASS. Commit:fix(cluster): all four role-scoped validators read the authority — 'Driver' no longer skips every driver rule (archreview 01/C-9).
T5 — Gossip-side predicates: GossipedRolesHave at the six ordinal sites
Classification: medium-risk (Primary election + the R2-04 gate's member count)
Estimated implement time: 10 min
Parallelizable with: T4, T6, T7; after T1
Files: RoleParser.cs, RedundancyStateActor.cs, ManualFailoverService.cs, ClusterNodeAddressReconcilerActor.cs, DriverHostActor.cs, ScriptedAlarmHostActor.cs, ClusterRoleInfo.cs, AdminUI/Components/Pages/Fleet.razor, tests/Core/…Cluster.Tests/GossipedRoleMatchTests.cs (new), a RedundancyStateActor selection test
- Tests:
GossipedRolesHavetruth table; and aRedundancyStateActorselection case where the member gossips"Driver"and a Primary is elected. Run → FAIL. - Implement the helper and adopt it at all seven sites (incl.
SelectOldestUpMemberOfRole);ClusterRoleInfo's gossip index becomesOrdinalIgnoreCase-keyed. - Filters → PASS; run
Cluster.Tests,ControlPlane.Tests,Runtime.Tests. Commit:fix(cluster): gossiped-role predicates compare case-insensitively — a miscased peer no longer un-elects the Primary.
T6 — Both-sources-disagree Warning + source= in the boot banner
Classification: standard (observability)
Estimated implement time: 5 min
Parallelizable with: T4, T5, T7; after T3
Files: EffectiveRoles.cs, Program.cs, EffectiveRolesTests.cs
- Test: both sources set to different normalized sets ⇒ the resolve reports a diagnostic the caller logs (return it on the type — do not log from a static factory;
Program.cslogs it after Serilog is configured). Run → FAIL. - Implement + emit at boot. Filter → PASS. Commit:
feat(host): warn when Cluster:Roles and OTOPCUA_ROLES disagree; boot banner names the winning source.
T7 — Post-overlay config-layer guard in AkkaClusterOptionsValidator
Classification: standard (narrow fail-fast; no-op today)
Estimated implement time: 5 min
Parallelizable with: T4, T5, T6; after T2
Files: AkkaClusterOptionsValidator.cs, tests/Core/…Cluster.Tests/AkkaClusterOptionsValidatorTests.cs
- Test: a configuration layer applied after role resolution changes
Cluster:Roles⇒Failnaming both views; the ordinary case ⇒Success. Run → FAIL. - Implement (set-equality against the injected
EffectiveRoles.Gossiped). Filter → PASS. Commit:fix(cluster): fail start when a late config layer changes Cluster:Roles (role overlays may not set it).
T8 — RoleAuthorityGuardTests — the rule cannot be stated twice again
Classification: high-risk verification (this is the deliverable of theme 4, not a nicety)
Estimated implement time: 15 min (the one deliberately-larger task — source scan + allow-list + comment stripping)
Parallelizable with: T1-T7 for scaffolding; the assertions only go green after T3+T4+T5
Files: tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleAuthorityGuardTests.cs (new)
- Write the three rules per Finding 2 (no
OTOPCUA_ROLESoutsideEffectiveRoles.cs/RoleParser.cs; no"Cluster:Roles"key outsideEffectiveRoles.cs; no bare"admin"/"driver"/"dev"role literal outsideRoleParser.cs), scanningsrc/**/*.cs+src/**/*.razorfrom the.slnxroot, stripping//////lines, reporting every offender asfile:line, with a named allow-list each entry of which carries a reason. - Run on the pre-T3 tree → FAIL, listing at minimum
Program.cs:61,62,63andFleet.razor:149,159. Paste that list into the commit body — it is the proof the guard can fail. - After T3-T5, run → PASS. Commit:
test(cluster): guard that role reads live only in the authority (archreview theme 4).
T9 — Cluster:Roles-only node gets the driver stack (integration)
Classification: high-risk verification (the exact shape 01/S-12 describes)
Estimated implement time: 15 min (harness plumbing)
Parallelizable with: T5-T8; after T3
Files: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ClusterRolesOnlyBootTests.cs (new)
- Build the host on the
DriverOnlyNoConfigDbBootTestsdirect-host-build pattern withCluster:Roles = ["driver"]andOTOPCUA_ROLESunset (viaEnvVarScope(null)); assert the driver stack is registered (ILocalDb,ApiKeyOpcUaUserAuthenticatornot the Null one, runtime actors) andAkkaClusterOptions.Roles == ["driver"]. - Negative control:
git stashT3's change (or run against master) → the test must FAIL with the Null authenticator / missingILocalDb. Record it. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~ClusterRolesOnlyBootTests"→ PASS. Commit:test(integration): a Cluster:Roles-only node wires the driver stack.
T10 — Docs sweep
Classification: standard (docs) Estimated implement time: 15 min Parallelizable with: all Files: per the "Docs to update" table
Rewrite docs/Configuration.md:126 (the note that currently documents the defect as design), plus the seven other rows. Commit: docs: one role authority — precedence, normalization, and the overlay invariant (R3-04).
T11 — docker-dev rig: make the divergence observable
Classification: standard (rig config) — but it is what makes T13 non-vacuous
Estimated implement time: 5 min
Parallelizable with: T10; before T13
Files: docker-dev/docker-compose.yml, docker-dev/README.md
- Drop
OTOPCUA_ROLESfrom site-b-2 (keep itsCluster__Roles__0/1), and add a comment naming this as theCluster:Roles-only pin for archreview 01/S-12 — site-b is already the rig's A/B control node. Leave the other five nodes as they are (they are the "both sources agree" control). - Add a
docker-dev/README.mdrow saying so, and that the node is expected to fail deploys on a pre-R3-04 build. - Commit:
chore(docker-dev): site-b-2 is Cluster:Roles-only — the rig can now see the two-authority split.
T12 — Optional: a miscased pin
Classification: standard (rig config)
Estimated implement time: 5 min
Parallelizable with: T11; before T13
Files: docker-dev/docker-compose.yml
Spell site-a-2's Cluster__Roles__0 as Driver (capital D), keeping cluster-SITE-A verbatim, as the standing 01/C-9 pin. Expected post-fix: site-a still elects exactly one Primary and /hosts shows a lowercase driver chip on both nodes. Pre-fix: site-a elects none. Commit: chore(docker-dev): site-a-2 pins a miscased Cluster:Roles entry (archreview 01/C-9).
T13 — LIVE GATE: docker-dev six-node rig — operator/rig task
Classification: HIGH-RISK verification gate (merge-blocking)
Estimated implement time: ~40 min rig session
Parallelizable with: nothing (needs the built branch + T11/T12)
Files: none (rig session; findings recorded in archreview/plans/STATUS.md)
How roles are set on the rig today: every node sets OTOPCUA_ROLES and Cluster__Roles__* to the same values — central-1/2 admin,driver,cluster-MAIN (:298/:352-354, :448/:485-487), site-a-1/2 driver,cluster-SITE-A (:564/:606-607, :686/:716-717), site-b-1/2 driver,cluster-SITE-B (:782/:803-804, :853/:874-875). T11/T12 make two of them divergent on purpose. Login is disabled on the rig, so drive the AdminUI directly at http://localhost:9200 (round-robins central-1/central-2 — rebuild both).
- Pre-fix baseline (on master, with T11/T12's compose applied).
docker compose -f docker-dev/docker-compose.yml up -d --build→ confirm the failure is real: site-b-2's log shows the boot banner with no roles (roles=[]),/hostsshows it gossipingdriverwhile/fleetcounts it, and a fleet deploy sealsTimedOutnaming site-b-2 as silent. site-a shows no Primary (both nodes'Server_ServiceLevellow;/hostsshows nodriverleader role). Record both — without this step the gate proves nothing. - Build the branch:
docker compose -f docker-dev/docker-compose.yml up -d --buildon all six. - Role parity, per node:
docker logs otopcua-dev-<node>-1 | grep "starting with roles"→ each node's banner names the same role set its compose entry declares, withsource=Configured, and the two divergent nodes report the config roles (site-b-2driver,cluster-SITE-B; site-a-2driver,cluster-SITE-A— lower-cased). Cross-check against/hosts, which renders the gossipedm.Rolesper member (Hosts.razor:487): the chips must match the banners exactly, on all six. That comparison — banner (Host view) vs/hostschips (Akka view) — is the live form of the "two views agree" guard. - site-a Primary election (01/C-9):
/hostsshows exactly onedriverleader role in the SITE-A pair; Client.CLIreadofServer_ServiceLevelonopc.tcp://localhost:4842vs:4843shows the 250/240 split. Pre-fix this leg showed neither. - Deploy seals Applied (01/S-12): deploy a config from
/fleet; it must sealAppliedwith an ack from all six nodes including site-b-2 (pre-fix:TimedOut). Confirm site-b-2 actually serves — Client.CLIbrowse -u opc.tcp://localhost:4845returns the deployed tree. - Fail-fast still fails (T4 regression): temporarily set site-b-1's
Cluster__Roles__0toDrivr,docker compose up -d site-b-1→ the container must exit with the unknown-role message namingCluster:Roles. Revert. - Record every leg in
archreview/plans/STATUS.mdwith the pre-fix baseline beside the post-fix result.
Sequencing summary
T1 → T2 → T3 → T9 (the authority, then the Host, then its integration proof)
↘ T4, T5, T6, T7 (validators / gossip predicates / warning / overlay guard — parallel after T2)
T8 scaffolded early; green only after T3+T4+T5
T10 anytime
T11, T12 (rig config) before T13; T13 last, after everything is merge-ready
Overall effort: ~1 dev-day code+unit, plus the rig session. The three HIGH-RISK slices (T1 identity type, T3 composition root, T8 the guard) each land behind their full project suites before anything stacks on them; T13 is merge-blocking and is only meaningful because T11/T12 gave the rig a node that can actually exhibit the defect.
Execution notes (recorded during T1–T12 implementation, 2026-08-20)
T5 — SelectOldestUpMemberOfRole/SelectDriverPrimary cannot be reached from this repo; the plan
was wrong about this one call site. RedundancyStateActor.SelectOldestUpMemberOfRole
(RedundancyStateActor.cs:172-178) is a thin wrapper around ClusterActiveNode.OldestUpMember
(ZB.MOM.WW.Health.Akka, a separately-versioned NuGet package out of scadaproj), which itself
matches via Akka's own Member.HasRole — an ordinal comparison this repo does not own. This is
structurally identical to the "not ours to change" carve-out the plan's own choice 2 already makes
for ClusterSingletonOptions.Role (rows 18/19); the plan simply did not notice the same constraint
applies to SelectOldestUpMemberOfRole before naming it as a GossipedRolesHave adoption site.
Consequence: the plan's stated T5 test — "a RedundancyStateActor selection case where the member
gossips 'Driver' and a Primary IS elected" — is not achievable. What shipped instead
(tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/GossipedRoleCaseInsensitivityTests.cs):
GossipedRolesHavewas implemented and adopted at every site that genuinely reads gossiped roles directly in this repo:RedundancyStateActor.cs(BuildSnapshot's own role classification, NOT the Primary-selection call),ManualFailoverService.cs(OrderedDrivers),ClusterNodeAddressReconcilerActor.cs(FilterOwnClusterDriverMembers),DriverHostActor.csandScriptedAlarmHostActor.cs(DriverMemberCount), andFleet.razor(the bare"driver"literals, nowRoleParser.Driver— the actual case-insensitivity there comes fromClusterRoleInfo's_roleLeaders/_membersByRoledictionaries moving toOrdinalIgnoreCase, whichIClusterRoleInfo.MembersWithRole/RoleLeaderread through).A_member_gossiping_a_miscased_driver_role_classifies_Secondary_not_Detachedpins the fix that is achievable: a single member gossiping"Driver"now classifiesSecondaryin the redundancy snapshot (wasDetachedpre-fix) — real value, because/hostsand/fleetrender this list, even though that member can still never be named Primary by itself.Primary_election_itself_remains_ordinal_and_still_elects_no_one_for_an_all_miscased_pairpins the true (negative) outcome instead of asserting a false positive: a node (or pair) gossiping only"Driver"still elects no Primary, because that decision runs through the vendored ordinal match. This is a genuine residual — not closed by R3-04 — worth a follow-up issue againstZB.MOM.WW.Health.Akka'sClusterActiveNode.OldestUpMember(e.g. an optional case-insensitive role-match parameter) if the rolling-upgrade window this defends against is judged worth closing fully. In practice T1/T2 already close the headline outage the census describes for a FIXED-build node (a node can no longer gossip a miscased plain role itself — normalization happens before the gossip handoff), so this residual only bites a peer still running a pre-R3-04 build.ManualFailoverService_recognises_a_miscased_driver_node_as_a_candidateusesGetSnapshot()/PrimaryAddressrather thanFailOverCore(dryRun: true)as originally sketched —FailOverCorerefuses below 2 driver members ("nothing to fail over TO"), which a single-node fixture can never satisfy;GetSnapshot()shares the sameOrderedDriverscandidate selection this test is actually about, without needing a second live node.
No other T1–T5 deviations from the plan.
T6-T9 — no deviations. Implemented as designed; AkkaClusterOptionsValidator gained an optional
(default-null) EffectiveRoles? constructor parameter rather than a required one specifically so the
pre-existing zero-arg test constructions in AkkaClusterOptionsValidatorTests did not all need
touching — the guard is simply inert when nothing is injected, which is also the correct behaviour
for any harness that builds this validator without the rest of the role authority.
T8 — two additional bare-role-literal offenders found and fixed, beyond the two the plan named. The census's row-by-row list did not claim to be exhaustive of every bare literal in the tree, only of the production predicate sites; running the guard for real (rather than reasoning about it) found two more before it could go green:
DriverInstanceActor.ShouldStub'sroles.Contains("dev")— a genuine Akka-cluster-role read (_localRoles, threaded fromIClusterRoleInfo.LocalRoles) gating dev-stub mode for Windows-only drivers. Fixed toRoleParser.GossipedRolesHave(roles, RoleParser.Dev).ControlPlane/ServiceCollectionExtensions.AdminRole = "admin"— a second constant duplicating the literalRoleParser.Adminnames, feeding Akka's ownClusterSingletonOptions.Rolematching (the same "not ours to change" ordinal match asSelectOldestUpMemberOfRole). The VALUE was already correct (Akka's own matching is unaffected either way); fixed to aliasRoleParser.Adminrather than duplicate it, matching the siblingRuntime/ServiceCollectionExtensions.DriverRolepattern.
The guard also required a small allow-list (six files) for bare "admin"/"driver"/"dev" literals
that name something OTHER than an Akka Cluster:Roles role — a raw-tree node-KIND discriminator, an
ACL-scope entity-type name, the Sql driver's own TagConfig JSON marker (×3 files), and an LDAP-derived
AdminUI RBAC role. Each carries its reason in RoleAuthorityGuardTests.RoleLiteralAllowListWithReasons,
per the plan's "add to the allow-list WITH A REASON" instruction; a companion test
(Every_allow_list_entry_has_a_reason_and_still_applies) fails if an entry goes stale.
The falsification control (The_scan_actually_detects_the_pre_R3_04_offenders) could not literally
re-run the guard against the pre-T3/pre-T5 GIT COMMITS (T1–T8 landed sequentially in this same
worktree, so "the tree" is always post-fix by the time T8's test runs) — it instead feeds the scan
logic the exact pre-fix Program.cs/Fleet.razor snippets as string literals, captured verbatim
from this session's own earlier reads of those files before they were edited. Functionally
equivalent proof-of-falsifiability; recorded as a deviation because it is a different MECHANISM than
"run on the pre-T3 tree," not because the result differs.
T9 — scope note, not a deviation. "The driver stack" is proven via two representative slices
(ILocalDb resolution + the IOpcUaUserAuthenticator selection block), not the ENTIRE
WithOtOpcUaRuntimeActors Akka actor graph — replicating that too would need a real ActorSystem
(the exact thing DriverOnlyNoConfigDbBootTests's own doc comment explains this test family avoids).
The negative control uses an inline PreFixDerivation helper (reading OTOPCUA_ROLES only, matching
pre-T3 Program.cs:61-63 verbatim) rather than a git stash, for the same "already sequential
commits in one worktree" reason as T8's falsification control — and it is run in the SAME test
process against the SAME configuration, which is a stronger control than a separate stash-and-revert
cycle would have been (both derivations are proven against literally identical input).
T11/T12 — combined into one commit. The docker-compose.yml header comment documenting all six
nodes' role sources interleaves site-a-2 and site-b-2 on adjacent lines, so a clean git-level split
into the plan's two separate commits was not practical; both tasks' edits landed in one commit
(985de0b6). The rig was NOT brought up as part of either task, per the brief.
Review outcome
A code review after T1–T12 returned verdict FIX-FIRST, two items. Both are fixed, each in its own TDD commit, on top of the T1–T12 work above.
1. [Blocking] T7's post-overlay guard was structurally unreachable
The finding. AkkaClusterOptionsValidator.ValidateAgainstEffectiveRoles compared
options.Roles against _effectiveRoles.Gossiped — but ServiceCollectionExtensions.cs's
PostConfigure (T2's OWN structural-identity fix) unconditionally sets
options.Roles = [.. effectiveRoles.Gossiped] first. The two values are therefore equal by
construction, on every real host, always — the guard could never observe a divergence no matter
what a role overlay did to Cluster:Roles. My original two tests (now deleted) hand-built an
AkkaClusterOptions instance directly and set .Roles to a "mismatched" value bypassing
PostConfigure entirely — which exercised the comparison logic in isolation but never proved the
guard reachable through the real pipeline. Concretely, before this fix: an overlay that set
Cluster:Roles was silently ignored (PostConfigure overwrote it back to the pre-overlay value), not
failed at boot — the exact opposite of the guard's purpose (choice 4: "a role overlay may not set
Cluster:Roles").
The fix. AkkaClusterOptionsValidator now also takes an optional IConfiguration? (default
null, same pattern as EffectiveRoles? — so every pre-existing zero/one-arg test construction keeps
compiling and behaving as before). The guard re-reads the RAW Cluster:Roles section directly off
the LIVE configuration (_configuration.GetSection(EffectiveRoles.ConfiguredRolesKey).Get<string[]>())
rather than the bound options, normalizes it by reusing EffectiveRoles.Resolve(raw, envRoles: null)
(one rule, stated once — archreview theme 4, rather than re-implementing the configured-path
normalization a second time), and compares that against _effectiveRoles.Gossiped. Skipped entirely
when the raw post-overlay value is empty — required for correctness, not merely tidiness: an
OTOPCUA_ROLES-only node (most of production, per CLAUDE.md) has an empty Cluster:Roles both
before AND after any overlay, and comparing "empty" against a non-empty env-derived Gossiped list
would be a permanent false positive on every one of those nodes. AddOtOpcUaCluster now also
TryAddSingleton(configuration) so a bare ServiceCollection test harness (not just
WebApplicationBuilder, which auto-registers IConfiguration) can supply it.
The tests. Both guard tests are rewritten to go through the REAL AddOtOpcUaCluster +
PostConfigure pipeline, over a ConfigurationManager (not a one-shot ConfigurationBuilder) — the
same "add a provider to the SAME live object after the fact" shape WebApplicationBuilder.Configuration
has, which is what lets a role overlay be simulated faithfully: EffectiveRoles.Resolve(configuration)
is called and its result registered BEFORE a second AddInMemoryCollection call mutates the same
configuration object with different Cluster:Roles values, mirroring Program.cs's own
resolve-then-AddJsonFile(overlay) sequencing exactly. Confirmed red first: the "changes" test
failed with "should throw OptionsValidationException but did not" against the pre-fix (options.Roles
-comparing) validator — direct proof of the reviewer's structural-unreachability claim. Two
supporting tests were added beyond the two named in the review comment: No_overlay_at_all_passes
(the ordinary case) and EffectiveRoles_injected_without_IConfiguration_skips_the_guard_entirely
(the two-optional-dependency ctor's other skip leg). One self-inflicted false-positive surfaced and
was fixed along the way: the new catch-block's error message split "Cluster:Roles." onto its own
string-literal token, which RoleAuthorityGuardTests' own space-inside-the-literal heuristic (T8)
correctly flagged as NOT looking like an operator sentence — merging it back into one literal
containing surrounding words fixed it without weakening the guard.
2. [Important] ClusterRoleInfo._roleLeaders must stay Ordinal, not OrdinalIgnoreCase
The finding. T5 moved BOTH _roleLeaders and _membersByRole to OrdinalIgnoreCase under one
shared rationale (defence in depth against a miscased rolling-upgrade peer). That rationale is right
for _membersByRole ("every member carrying a spelling of this role" — merging casings is the
point) but wrong for _roleLeaders: Akka computes — and raises RoleLeaderChanged for — role
leadership per exact gossiped role string. "driver" and "Driver" are two entirely distinct roles
to Akka's own role-leader machinery, each independently electing its own leader. Keying
_roleLeaders case-insensitively collapses both onto ONE dictionary slot, so whichever
RoleLeaderChanged event landed last silently overwrites the other's leader — turning two real,
independent facts Akka tracks separately into one, wrong, merged fact.
The fix. _roleLeaders reverted to StringComparer.Ordinal; _membersByRole stays
OrdinalIgnoreCase (the split itself, not merely one value, is now documented in the field
comment). No other production call site touches _roleLeaders's comparer, so this is a one-line
revert plus doc.
The test. ClusterRoleInfoRoleLeaderCaseTests.RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries
— a real two-node cluster (the only faithful option: HandleRoleLeaderChanged is internal with no
InternalsVisibleTo, so it cannot be unit-tested directly, and ClusterRoleInfo's constructor needs
a genuinely clustered ActorSystem). Node A gossips only "driver", node B (joining the same
cluster) gossips only "Driver" — each is therefore the sole, and so leader, member of its own
exact-string role. Confirmed red first: against the pre-fix (OrdinalIgnoreCase) comparer, the test
failed with driverLeader and capitalDriverLeader resolving to the SAME address — the exact
collision the review predicted, reproduced live rather than merely reasoned about.
Optional item — done
Added one sentence to docs/ServiceHosting.md's "Per-role configuration overlays" section: a plain
role's overlay-filename fragment is always lower-case regardless of how it was spelled in
Cluster:Roles, but a cluster-{ClusterId} role's suffix carries into the filename verbatim (since
EffectiveRoles.Gossiped preserves that spelling by design) — e.g.
Cluster:Roles: ["driver", "cluster-SITE-A"] loads appsettings.cluster-SITE-A-driver.json.
Verification
dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors throughout.
- After item 2 (
ClusterRoleInfo):Cluster.Tests241 → 242/242 (+1,ClusterRoleInfoRoleLeaderCaseTests). - After item 1 (T7 guard):
Cluster.Tests242 → 244/244 (net +2: the two old hand-built T7 tests deleted, four new ones added —An_overlay_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views,An_overlay_that_repeats_the_same_Cluster_Roles_passes,No_overlay_at_all_passes,EffectiveRoles_injected_without_IConfiguration_skips_the_guard_entirely— the pre-existingNo_EffectiveRoles_injected_skips_the_guard_entirelywas kept unchanged). Host.Tests106/106, unchanged by either fix (Program.csitself was not touched).ControlPlane.Tests267/267 — an extra safety check beyond what was asked, sinceClusterRoleInfois a shared dependency ofRedundancyStateActoret al.
No pushes, no merges.
Live gate result (2026-08-20) — PASS on docker-dev (pre-fix control at 7c77a8c7 failed first)
Rig: docker-dev/docker-compose.yml on OrbStack (Mac), fresh volumes (down -v before the control), the
six host containers + SQL + Traefik, AdminUI on Security__Auth__DisableLogin=true. The compose carried the
T11/T12 pins in BOTH runs (site-b-2 Cluster__Roles__* only, no OTOPCUA_ROLES; site-a-2
Cluster__Roles__0="Driver"). Deploys via ./docker-dev/deploy.sh (POST /api/deployments,
X-Api-Key: docker-dev-deploy-key, 202 both times). Server_ServiceLevel (i=2267) read with Client.CLI on
:4840–:4845. Image parity asserted ×6 via docker inspect --format '{{.Image}}' in both runs. The Modbus
fixture on 10.100.0.35:5020 was left running throughout. Note: master advanced 9242630c → f665e8b8 (R3-05 S18
follow-up) while the post-fix run was in progress; the post-fix image had already been built from the
9242630c working tree (image created 13:29:46 local, merge landed 13:29:59) — the only later delta is
BoundedApiKeyStore.cs + a compose env wire, neither of which touches roles.
Leg 1 — pre-fix control, image otopcua-host:prefix-r304 = 32a2dcee0285 (scratch worktree at 7c77a8c7) ×6: FAILED as required
| what the plan predicted | observed |
|---|---|
| site-b-2 boot banner shows no roles | [17:25:22 INF] OtOpcUa.Host starting with roles=[] (admin=False, driver=False) — every other node roles=[admin,driver,cluster-main] / [driver,cluster-site-a] / [driver,cluster-site-b] (pre-fix banner lower-cases the cluster suffix; no source=) |
site-b-2 gossips driver anyway |
site-b-1: Node [akka.tcp://otopcua@site-b-2:4053] is JOINING, roles [cluster-SITE-B,driver]; site-b-2's own cluster-primary health check: Standby: active node for role 'driver' is akka.tcp://otopcua@site-b-1:4053 — Akka counted it as a driver member while the Host wired nothing |
| fleet deploy seals TimedOut naming site-b-2 | Deployment 6c653c61… (rev 942ad3cc…): Status = 4, SealedAtUtc = NULL; NodeDeploymentState five rows Status = 1 (AppliedAtUtc 17:25:41.077–.107), site-b-2 Status = 0, AppliedAtUtc = NULL. central-2: [17:27:41 WRN] Deployment 6c653c61… timed out after 00:02:00 (5/6 acks landed). No ack from: site-b-2:4053. Each is an enabled, non-maintenance ClusterNode row … |
| site-b-2 does not serve | Client.CLI read on opc.tcp://localhost:4845 → Remote side closed connection (no OPC UA server started; no DriverHost / Kestrel lines in its log at all, only Akka remoting + cluster join) |
| site-a (01/C-9): "elects no Primary" | Partly wrong, and the execution notes already said why. site-a-1 gossips ordinal driver, so ClusterActiveNode.OldestUpMember still elected it: :4842 = 250. What failed was site-a-2: gossiped roles [cluster-SITE-A,Driver] (site-a-1's JOINING line), its own cluster-primary check Node carries none of the active roles: admin, driver., and :4843 = 0 — the pair ran with a Primary and no eligible standby. A pair whose nodes were BOTH miscased would elect none (pinned by Primary_election_itself_remains_ordinal_and_still_elects_no_one_for_an_all_miscased_pair); the rig pins only one |
| control reference | :4840=240, :4841=250 (MAIN), :4844=250 (site-b-1 sole eligible driver) |
Leg 2 — post-fix, image otopcua-host:dev = b7cfae1be510 (master 9242630c) ×6: PASS
- Role banners, all six
source=Configured, config roles win, normalised — central-1/2starting with roles=[admin,driver,cluster-MAIN] (admin=True, driver=True) source=Configured; site-a-1/site-a-2roles=[driver,cluster-SITE-A](theDriverpin lower-cased, cluster suffix verbatim); site-b-1/site-b-2roles=[driver,cluster-SITE-B] (admin=False, driver=True)— theCluster:Roles-only node now wires the driver stack. Gossip view agrees with the Host view on every node: site-a-1Node [site-a-2] is JOINING, roles [cluster-SITE-A,driver](wasDriver), site-b-1Node [site-b-2] is JOINING, roles [cluster-SITE-B,driver], central-2Node [central-1] is JOINING, roles [cluster-MAIN,driver,admin]. (/hostschips were not used as the cross-check — the page is a Blazor circuit and acurlof it renders no member rows; the Akka JOINING lines are the same gossipedm.Rolesthe page reads.) - Deploy seals Applied 6/6 (01/S-12): Deployment
a5a7018d…(same revision942ad3cc…):Status = 2,CreatedAtUtc 17:31:21.914,SealedAtUtc 17:31:22.138(224 ms); sixNodeDeploymentStaterows allStatus = 1,AppliedAtUtc 17:31:22.090–.132including site-b-2. central-1:Deployment a5a7018d… sealed (acks=6). site-b-2:Fetched + verified artifact … from http://central-1:4055 (1468 bytes)→DriverHost site-b-2:4053: applied fetched deployment a5a7018d… (rev 942ad3cc…, children=0)→cached … as this node's local fallback. Client.CLIbrowse -u opc.tcp://localhost:4845 -d 2returnsServer,Aliases,Locations,OtOpcUa (ns=2;s=OtOpcUa)— site-b-2 serves (wasRemote side closed connection). - One Primary per pair, with an eligible standby (01/C-9):
:4840=240/:4841=250 (MAIN),:4842=250 /:4843=240 (SITE-A — site-a-2 was 0 pre-fix; its health check now readsStandby: active node for role 'driver' is akka.tcp://otopcua@site-a-1:4053),:4844=250/:4845=240 (SITE-B — site-b-2 was unreachable). After the leg-5 restart of site-b-1 the SITE-B pair re-elected with site-b-2 as Primary (:4845=250,:4844=240) — the formerly role-less node now holds the Primary-gated data plane. - T6 disagreement Warning fires: scratch compose override on site-b-1 setting
OTOPCUA_ROLES: "driver"against itsCluster__Roles__0/1 = driver,cluster-SITE-B→[17:32:35 INF] … starting with roles=[driver,cluster-SITE-B] … source=Configuredfollowed by[17:32:35 WRN] Cluster:Roles ([driver,cluster-SITE-B]) and OTOPCUA_ROLES ([driver]) disagree; Cluster:Roles wins outright.— and the node still joined and served with the config roles. (The rig's standing pins do NOT fire this Warning by themselves: site-a-2'sDrivernormalises equal to its env value, and site-b-2 has only one source — choice 5 is explicitly "both set AND differ".) - Unknown role fails host start: override
Cluster__Roles__0: "operator"on site-b-1 → the log is exactly six lines:Unhandled exception. System.ArgumentException: Cluster:Roles[0] = 'operator' is not a known role. Allowed: admin, driver, dev, or 'cluster-<ClusterId>'.atEffectiveRoles.NormalizeAndValidateConfigured←EffectiveRoles.Resolve(IConfiguration)←Program.cs:67— no boot banner, no Akka remoting, no Kestrel, no cluster join. Override removed; site-b-1 recreated from the plain compose →roles=[driver,cluster-SITE-B] … source=Configured, no Warning,(healthy). ⚠️ Observation, pre-existing and NOT R3-04's: thedotnet OtOpcUa.Host.dllprocess did not exit after the unhandled exception —docker inspectkept reportingrunning exit=0 restarts=0for > 2 min, the main thread in stateRburning ~50 % of a core with 14 Server-GC threads parked. Reproduced identically on a baredocker run --rm -e OTOPCUA_ROLES=operatoragainst bothotopcua-host:prefix-r304(7c77a8c7, pre-fixRoleParser.Parsethrow atProgram.cs:61) and the master image: neither exits within 90 s (timeout→ 124). So "fail-start" holds (nothing is wired, nothing listens) but the container'sunless-stoppedrestart policy never fires and an orchestrator watching the exit code would not see a crash; only the compose healthcheck (neverhealthy) shows it. Arm64 OrbStackaspnet:10.0runtime image; root cause not chased here — worth a follow-up (is it the runtime's unhandled-exception abort path on this host, or something in ourProgram.cstop-level statements?) before relying on a process-exit signal for any of the boot-time validators (SplitTopologyTransportValidator,AkkaClusterOptionsValidator,ConfigSourceOptionsValidatorfail the same way).
Leg 3 — restore
Six hosts on b7cfae1be510 ×6 (parity asserted), GET /health/ready → 200 via :9200, SQL + Traefik up, all six
ServiceLevels one 250 + one 240 per pair; otopcua-pymodbus-standard on 10.100.0.35 untouched (Up 2 weeks). Scratch
worktree at 7c77a8c7 removed; the control image tag otopcua-host:prefix-r304 left in the local image cache (same layer
id as R3-03's prefix-r303 — both are 7c77a8c7).
Verdict: T13 PASS. Both findings reproduce on the pre-fix build with the rig as pinned (01/S-12 as a real fleet deploy outage; 01/C-9 as a standby-less pair rather than the plan's "no Primary" — the execution notes' ordinal-election residual, confirmed live), and neither reproduces on master. One pre-existing process-does-not-exit-after-fail-start observation recorded above for follow-up.