Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the
"not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/
metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig
in both directions -- required ids + optional deviceId, dataType/qos read
strictly but only when present (dataType is optional: the birth certificate
declares it). One rule is deliberately stricter than the factory: group/edge-
node/device ids reject '/', '+', '#' (topic-segment characters a decoded
incoming id can never carry, so an authored one that does can never bind);
metricName is exempt since Sparkplug's own canonical names use '/' (e.g.
"Node Control/Rebirth"). No FullName key is written -- TagConfig carries no
identity under v3, and the plan's snippet asking for one was corrected per the
brief. A SparkplugB tag now survives save/reopen because its descriptor keys
re-infer Mode on load; there was never a 'mode' key to persist.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and
DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON
fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built
the *tag* editor; these are the driver + device surfaces.
MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId,
TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect
backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 —
a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys
preserved untouched.
The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and
contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up
only when the driver has exactly ONE device, so a device-authored broker connection vanishes
silently the moment a second device is added. MqttDeviceForm is therefore informational (the
GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys
left on a device by a pre-form deployment, because those still win via merge-up.
Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a
fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted
blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's
serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping
the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green —
the Task 9 lesson, reproduced.
RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level
keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and
ToOptions() additionally clamps, so an ignored error still cannot persist a
connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning
inline (fixed ids make redundant pair nodes evict each other while both report Healthy).
AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The read-only Sql driver's runtime factory/probe and tag-side editors were
wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type
list, the DriverConfigModal switch, and the legacy identity dropdown all omitted
Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this.
- Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider
(SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a
connection string — label is explicit), optional poll/operation/command
timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via
JsonStringEnumConverter; a connection-string literal can never be authored
(no such field is collected); rawTags (composer-owned) + allowWrites (inert,
read-only v1) are never emitted.
- Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and
DriverIdentitySection legacy dropdown.
- Add SqlDriverFormContractTests: the exact JSON the form emits constructs a
driver through the real factory, missing connectionStringRef is rejected, and
provider round-trips as the "SqlServer" name (never an ordinal).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 14 — the P1 milestone gate. Ran the live /run verification on a docker-dev
rig against the real Mosquitto TLS+auth fixture, and recorded the result.
The gate did its job: it found MQTT was unauthorable in production.
RawDriverTypeDialog's driver-type list is a hand-maintained array that nobody
added MQTT to, so the /raw "New driver" picker never offered it — with every
other layer (contracts, driver, browser, factory, host registration, typed tag
editor) complete and 266 green unit tests. Nothing tied that array to
DriverTypeNames and AdminUI has no bUnit, so no offline test could see it.
Fixed, plus RawDriverTypeDialogParityTests: a reflection parity guard that
fails for ANY DriverTypeNames entry missing from the picker. Verified
falsifiable — RED with "…cannot be authored from the /raw New-driver picker,
so they are unreachable in production: Mqtt" before the one-line fix.
A second gap is left OPEN and documented, not fixed (no task ever covered it):
MqttDriverForm / MqttDeviceForm do not exist, and DriverConfigModal/DeviceModal
have no raw-JSON fallback — so an operator can create an MQTT driver but cannot
author its broker endpoint or credentials. P1 is not operator-complete until
those two razor forms land. The gate authored config via SQL to get past it.
Live gate result (isolated 2-node MAIN stack, image built from this branch,
fixture at 10.100.0.35:8883, AllowUntrustedServerCertificate rather than
pinning the CA into the rig):
- typed MQTT tag editor renders and round-trips; Json shows the JSON-path
field, Raw/Scalar hide it; wildcard topic a/+/c blocked at save; data-type
list offers Float64 and no Double; qos/retainSeed absent on "(driver
default)" and qos:2 written as a number; isHistorized/historianTagname
survive a topic-only edit; SparkplugB renders the P2 placeholder and
switching back keeps Plain values; jsonPath is guidance not a gate in all
three shapes ($ seeded only for a brand-new blank tag, blank stays absent,
clearing saves fine)
- deployed, and both editor-written blobs resolved and served changing live
values through OPC UA (22.8 / 1629, StatusCode Good) — no BadNodeIdUnknown
- the bespoke #-observation browser drove a real broker session and rendered
the fixture's actual topic tree (line1/{counter,state,temperature},
noise/chatter, retained/seed); a Modbus device's picker still correctly
reports "Browsing unavailable"
Also found live and documented (not a code change): both nodes of a redundant
pair run the driver, so a FIXED clientId makes them evict each other forever —
the broker logs "already connected, closing old connection" and both nodes
reconnect every ~2s while still reporting Healthy. Unset (the default) is
correct; confirmed 0 reconnects/60s on both nodes after removing it.
Suites: offline 1134 passed / 0 failed (266 Driver.Mqtt + 730 AdminUI incl. the
3 new guards + 138 Core.Abstractions); live 7/7 against the fixture.
Docs: new docs/drivers/Mqtt.md + Mqtt-Test-Fixture.md (the per-driver + fixture
convention every other driver follows), MQTT rows in docs/drivers/README.md,
TestConnectProbes.md, root README.md, and infra/README.md §3.
CLAUDE.md drift corrected while adding the MQTT endpoints:
- the "every fixture carries project: lmxopcua" claim was false — only the
MQTT fixture does; the filter returns one stack, not the fleet
- lmxopcua-fix.ps1 is Windows-VM-only; on macOS drive the host over ssh/rsync
(and the docker-dev rig itself runs locally under OrbStack)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under
a Json payload: the runtime defaults it to the document root, which is the real
"publisher puts a bare JSON scalar on the topic" case, so rejecting it made the
editor refuse what the driver accepts — inverting "authoring surface accepts <=>
publish accepts" — and blocked whole CSV-import batches, since this validator
also gates RawManualTagEntryModal's review grid.
Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the
field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an
existing path-less tag keeps the key ABSENT and the driver defaults it. The
wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag
interpretation.
Also: omit a blank "topic" rather than writing "topic":"", and record why the
_lastConfigJson guard diverges from the Modbus template — the landmine is a
DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task
24's Sparkplug field group will be tempted to add more of. Named the host-side
dependency (RawTagModal only mutates through this callback) that keeps the
staleness trade benign today.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).
Carried-forward items:
- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
probe's internal JsonOpts and the browser's separate private copy; the factory,
the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
it. .Contracts is the only assembly all four consumers reference, and the
browser's reference to the runtime .Driver project is a documented layering
exception scheduled for removal — anchoring the options there would resurrect
the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
Ok == false + non-empty message, which is also exactly what a JSON-parse
failure produces — so it stayed green under the very regression it names.
It now asserts the probe got past the parse and reached the network.
Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.
Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Thin typed model over a preserved JsonObject key bag, mirroring the sibling
<Driver>TagConfigModel template. Key names and strictness rules mirror
MqttTagDefinitionFactory rather than inventing an editor-side schema; enums
round-trip as NAMES (the factory reads them strictly by name).
No FullName key: under v3 a tag is identified by its RawPath, which the factory
keys the definition's Name off — a composed identity key here would be dead
weight nothing reads.
Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug
descriptor key present) and never serialised — the driver takes Plain vs
SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the
Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Wires the bespoke MqttDriverBrowser into AdminUI's IDriverBrowser
registrations alongside OpcUaClient/Galaxy, overriding the universal
DiscoveryDriverBrowser fallback for driverType "Mqtt".
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Add DriverTypeNames.Sql (+ All) as the shared source of truth, and wire the driver
into the Host: register the factory in DriverFactoryBootstrap.Register via
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory), add the
SqlProbe (= Driver.Sql.SqlDriverProbe) probe via TryAddEnumerable, and add the
Driver.Sql project reference to the Host. Default tier A (SQL client is managed +
cross-platform, so ShouldStub needs no change).
Repoint the interim SqlDriver.DriverTypeName / SqlDriverFactoryExtensions.DriverTypeName
literals at DriverTypeNames.Sql; both still resolve to "Sql", so the AdminUI
TagConfigEditorMap/TagConfigValidator keys and the DriverTypeName-parity test stay
green. The Core guard test discovers factories from its own bin, so it also gains a
Driver.Sql project reference — that is the "registered-factory side" that lets
DriverTypeNamesGuardTests' bidirectional-parity check pass (4/4 green).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's
accept/reject boundary byte-for-byte on the fields they own: model/type
serialize as NAME strings (never numbers), KeyValue requires
table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a
selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys
(top-level and nested rowSelector) survive load->save. Registered in
TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName
(DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder
SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI.
The load-bearing test rounds editor output back through
SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy
lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor:
a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a
column attribute side-panel, manual-entry model/selector fields retained, that
composes the per-tag TagConfig blob (KeyValue / WideRow) matched to
SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and
never persisted into the composed blob or driver config.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
ClusterPrimaryHealthCheck was written three days' worth of debugging ago because
the shared ActiveNodeHealthCheck selected by RoleLeader and reported Healthy for
any node lacking the role. Health 0.3.0 fixes the shared check — oldest Up
member, role-preference scoping, Unhealthy when the node owns no active work —
so the private copy is now duplication rather than a workaround, and it is
deleted.
RolePreference [admin, driver] reproduces exactly what it did: a fused central
node answers for admin (where the singletons and the AdminUI are pinned), a
driver-only site node answers for driver.
SelectOldestUpMemberOfRole now delegates to the shared ClusterActiveNode instead
of re-implementing the age ordering. This is the point of the change rather than
a tidy-up: the redundancy snapshot drives the OPC UA ServiceLevel 250/240 split
while the health tier drives Traefik's admin routing, so two copies of "oldest Up
member of a role" would be two chances to advertise one node as authoritative
while gating the data plane on another. ControlPlane takes a ZB.MOM.WW.Health.Akka
reference for it; the layering trade-off is recorded at the PackageReference.
Behaviour note: a node carrying neither admin nor driver now answers 503 on the
active tier instead of 200. That case is reachable — RoleParser admits dev-only
and cluster-role-only nodes — and 503 is correct, since such a node owns no
active work and must not be in an active-tier pool. No docker-dev node is
affected: every rig node is admin+driver or driver-only.
Tests replaced in kind. The rule itself is now pinned in the library against a
real two-node cluster; what stays OtOpcUa's own decision is the wiring, so
ActiveTierRegistrationTests pins which check is on the active tag, that it is
registered on driver-only nodes too, and that it is not on the ready tier.
Verified: Host.Tests 25/25, ControlPlane redundancy 15/15.
Closes the remaining half of #494, and corrects the half fixed in 8dd9da7d.
The shared ActiveNodeHealthCheck(role: "admin") answered the wrong question twice
over. It returns Healthy for any node LACKING the role, so all four driver-only
site nodes called themselves active and no consumer could find the Primary of a
site Cluster. And it selects by RoleLeader - the lowest-ADDRESSED member - which
is not where Akka places singletons.
Replaced with ClusterPrimaryHealthCheck ("cluster-primary"). One rule: this node
is active iff it is the OLDEST Up member carrying its own active role, where the
active role is admin when the node has it and driver otherwise. That serves both
consumers correctly - a fused admin node answers for admin, so Traefik pins the
AdminUI to the node hosting the singletons; a driver-only site node answers for
driver, which is exactly SelectDriverPrimary, the same election behind
IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Per-mesh scoping is
free after Phase 6: ClusterState.Members already contains only this node's own
Cluster.
SelectDriverPrimary is generalised to SelectOldestUpMemberOfRole so the
age-ordering rule has one implementation. Two copies of "oldest Up member of a
role" would be two chances to silently disagree about who is in charge, and the
tier and the redundancy snapshot must never disagree.
THE ROLE-LEADER HALF MATTERED IN PRACTICE, not just in theory. On the rebuilt rig
the two orderings diverge right now:
akka leader (lowest address) = central-1
oldest admin member = central-2 <- hosts the singletons
8dd9da7d made the tier a real 503 but still selected by RoleLeader, so it would
have pinned Traefik to central-1 - the node NOT hosting the work. With this change
Traefik correctly routes to central-2.
Live-verified, exactly one 200 per mesh:
MAIN central-2 200 central-1 503 traefik: central-2 UP, central-1 DOWN
SITE-A site-a-1 200 site-a-2 503
SITE-B site-b-1 200 site-b-2 503
Tests: role-selection matrix and the startup-safe Degraded path in Host.Tests
(26 pass); parity between the tier's selector and the redundancy snapshot's, plus
role scoping, added to RedundancyPrimaryElectionTests, which forms a real two-node
cluster deliberately built so the oldest member is not the lowest-addressed one
(5 pass).
Closes the residual Phase-7 gap: when BOTH nodes of a 2-node pair cold-start at the
same instant, each self-first seed runs FirstSeedNodeProcess, times out waiting for
the other, and forms its own 1-node cluster — two Primaries in one pair (the Phase 6
live gate reproduced this reliably on docker). The prior mitigation was operational
(staggered start / compose depends_on), which does not exist on production hardware.
The guard (dark switch Cluster:BootstrapGuard:Enabled, default OFF) prevents the split
without giving up cold-start-alone:
- The lower-address node is the preferred founder: self-first, forms immediately.
- The higher node probes its partner's Akka port (TCP connect) up to PartnerProbeSeconds:
reachable => peer-first (join the founder, never race it); unreachable => self-first
(partner is dead, form alone). The order is decided BEFORE the single JoinSeedNodes,
from an explicit reachability signal — never re-formed mid-handshake (the retired
SelfFormAfter failure mode). When on, Akka gets no config seeds (BuildClusterOptions)
and ClusterBootstrapCoordinator drives the join.
Review-driven hardening: case-insensitive tie-break (a hostname-casing mismatch would
reopen the split); fail-fast validation of the timing knobs; the residual "founder dies
in the probe->join window" hang is documented and made operator-visible (warning + a
restart recovers). Real-ActorSystem coordinator tests cover the load-bearing higher-node
cold-start-alone case; 147/147 Cluster tests pass.
Live-gated on docker-dev (site-a = enablement demo, serialization removed, guard on;
site-b keeps depends_on-serialization + guard off as the A/B control): simultaneous
start -> site-a-1 founds, site-a-2 probes-reachable-joins -> 250/240, NO split;
higher-node-alone -> probes dead founder, self-forms -> 250; founder rejoin -> 240.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 6 wired the re-homed redundancy singleton in Program.cs as
`WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>())`
inside the AddAkka configurator lambda. That lambda runs WHILE the ActorSystem is
being built, but ClusterRoleInfo depends on the ActorSystem (it is a live
Cluster.State view) — so resolving it there recurses into ActorSystem construction
and every node dies at boot with StackOverflowException. It compiles cleanly (a
runtime DI cycle) and RedundancyStateSingletonRehomeTests passed a hand-built
FakeClusterRoleInfo straight into the extension, mocking around the composition-root
line that overflows. The docker-dev live gate caught it on first boot.
Derive the singleton's cluster-role scope from AkkaClusterOptions (pure config, no
ActorSystem) instead: BuildClusterRedundancySingletonOptions / the extension now take
AkkaClusterOptions and compute Role = Roles.FirstOrDefault(IsClusterRole) ?? driver —
identical to ClusterRoleInfo.ClusterRole, which derives from the same config. Program.cs
passes IOptions<AkkaClusterOptions>.Value, which has no ActorSystem dependency.
Live-verified: rebuilt image boots with zero stack-overflow lines, cluster singletons
form per mesh. 5/5 rehome tests pass; Host builds clean.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Redundancy.md: KNOWN-LIMITATION (per-Akka-cluster election) marked RESOLVED;
new 'Per-cluster meshes (Phase 6)' section + pair-local secrets note; manual-
failover mesh-scope caveat removed; auto-down 1-vs-1 note updated (every mesh is
now two nodes, drill deferred to Phase 7). IManualFailoverService + ClusterRedundancy
razor caveats updated to pair-local reality. Configuration.md documents the
cluster-{ClusterId} role, per-pair self-first seeds, SplitTopologyTransportValidator,
and the intentional Dps default. Program + design status tables + CLAUDE.md marked
Phase 6 DONE.
(Task 8 subagent completed the edits but died on an API error before committing;
reviewed and committed by the controller.)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The per-cluster contact map only gains a key when a row parses, so an
all-malformed cluster has no key at all and is caught by the absent-key arm;
the empty-set guard is defensive/unreachable for the DB producer. Comment-only
(Task 4 code-review minor follow-up).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 3 review: scoping only the rows/enabled DB queries by ClusterId left
the observed set (from Cluster.State.Members) filtered by the driver role
alone. In the mixed-mesh window — DB scoping live via a cluster role, but
the physical Akka mesh not yet split so central still sees foreign members
via gossip — a foreign-cluster driver member stays in observed while its row
is filtered out, hitting the RunningNodeHasNoRow branch, which logs at ERROR.
That traded the Warning-level false positive for a worse Error-level one.
Filter observed by the node's own cluster ROLE too. Extracted the projection
into a static FilterOwnClusterDriverMembers(members, ownClusterRole) so the
membership filter is unit-testable without seeding a live cluster (the actor
harness joins as admin only, so observed is always empty). Sourced at
registration from IClusterRoleInfo.ClusterRole alongside ClusterId. Null role
(legacy, no cluster role) keeps every driver member — reconcile-all, unchanged.
Also clarified the ownClusterId doc wording (non-null-and-non-empty).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Review follow-ups on the redundancy re-home (Phase 6, Task 2):
- Program.cs: the hasDriver comment claimed IClusterRoleInfo "throws at host
start if this driver node carries no cluster role" — false since the fallback
landed. Rewrite it to describe the cluster-{ClusterId} scope with the driver-role
fallback for legacy single-mesh / not-yet-migrated nodes.
- ClusterRoleInfo.cs: the SubscriberActor comment described RedundancyStateActor as
the "admin-role singleton" — stale. Note it is now a cluster-{ClusterId}-scoped
singleton spawned on every driver node (LeaderChanged stays a no-op here).
- Add a host/registry-level test for the driver-role FALLBACK path (roles ["driver"],
no cluster role) closing the coverage asymmetry — it was proven only in the pure
helper. Asserts the node boots and registers RedundancyStateActorKey, i.e. the boot
the earlier throw-based version would have aborted.
The boot helper now supplies an in-memory IDbContextFactory and a fake IClusterRoleInfo:
Akka.Hosting invokes singleton props factories eagerly at StartAsync, and the admin
ClusterNodeAddressReconciler factory reads both (the latter for its per-cluster reconcile
scope, added by a sibling Phase 6 task) — without them the admin boot NREs.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
After the fleet splits into one Akka mesh per application Cluster, a
ClusterClientReceptionist serves only its own mesh — so the single
fleet-wide client's SendToAll reached only the mesh whose receptionist
answered, leaving every other cluster silently on its old configuration.
Central now holds one ClusterClient per ClusterId and fans SendToAll
across all of them. LoadContactsFromDb selects ClusterId and groups the
receptionist contacts per cluster (keeping the per-row TryParse guard and
the enabled/non-maintenance filter); ContactsLoaded carries
ContactsByCluster; HandleContactsLoaded diffs per cluster (rebuild changed,
stop+drop vanished, warn-don't-create on empty); RebuildClient builds a
per-cluster client under a clusterId-derived actor name. IMeshClusterClientFactory.Create
gained a clusterId parameter so the per-cluster clients get distinct,
diagnosable names. ApplyAck handling and the DPS branch are unchanged; the
deploy path stays payload-free.
Tests: focused unit coverage of the grouping + fan-out (one-client-per-cluster,
fan-out SendToAll to every cluster client) via the recording factory double,
plus the real two-mesh boundary test extended to central + two separate site
meshes proving one dispatch reaches both and a client scoped to one cluster
never crosses into another. Red-before-green verified: crippling the fan-out
to a single client fails the reaches-both-meshes assertion.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 6 splits the fleet into one mesh per Cluster, so an admin (central)
node's Cluster.State.Members shows only its own pair — it no longer sees
site members via gossip. The ClusterNodeAddressReconciler singleton compared
live membership against EVERY ClusterNode row fleet-wide, so post-split every
foreign-cluster row would log EnabledRowNotInCluster forever.
Scope the actor's two DB queries to rows whose ClusterId matches the admin
node's own (IClusterRoleInfo.ClusterId, sourced at registration). A legacy
admin node with no cluster role (null ClusterId) still reconciles the whole
fleet — it genuinely sees every member via gossip. The pure Reconcile
function and AddressMismatchKind semantics are unchanged; scoping lives
entirely in the actor's queries.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 6 splits the single fleet-wide Akka mesh into one 2-node mesh per
application Cluster. RedundancyStateActor was an admin-scoped cluster singleton
that elected ONE fleet-wide driver Primary — but after the split a driver-only
site pair has no admin node in its mesh to host it, so its Primary would never
be elected.
Re-home it: remove the redundancy WithSingleton block from the admin
WithOtOpcUaControlPlaneSingletons set, and add WithOtOpcUaClusterRedundancySingleton,
a singleton spawned from Program.cs's hasDriver branch on EVERY driver node (the
fused central included). It scopes to the node's own cluster-{ClusterId} role when
present, and falls back to the fixed `driver` role otherwise. Each mesh then has
exactly one, electing its own pair-local Primary and publishing redundancy-state
on its own mesh's DistributedPubSub. The election logic (oldest Up driver member)
and the DPS publish are unchanged — they become pair-local after the split.
The driver-role fallback (Decision 2) deliberately does NOT throw when a node has
no cluster role: that is the pre-Phase-6 fleet-wide behavior on a legacy single
mesh, so legacy/harness (TwoNodeClusterHarness: admin,driver) and not-yet-migrated
deployments keep booting unchanged. On a genuinely split 2-node mesh the `driver`
role is already pair-local (the mesh IS the pair), so the fallback is correct in
both worlds. Cluster-scope, when present, additionally survives an accidental
two-mesh merge by keeping one singleton per cluster.
The role scope + driver-role fallback are pinned by unit tests against the
extracted BuildClusterRedundancySingletonOptions helper (the BuildDowningHocon
pattern); admin-removal + driver-registration are pinned behaviorally against a
booted node's ActorRegistry.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 6 (per-cluster mesh split) needs nodes to carry a cluster-scoped
role like cluster-SITE-A; RoleParser previously rejected anything
outside the fixed admin/driver/dev set. Adds IsClusterRole/
ClusterIdFromRole plus well-known-role constants, and points the three
duplicate "driver" string literals (RedundancyStateActor,
ClusterNodeAddressReconcilerActor, ServiceCollectionExtensions) at the
new RoleParser.Driver so the value has one source, without renaming
any existing DriverRole symbol.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Closes two test-completeness gaps flagged in review of the seam-tap commit — the
ScriptedAlarmHostActor and VirtualTagActor hub emits had no test proving the hub
actually receives them.
- ScriptedAlarm_activation_emits_one_Alarm_item_with_matching_payload: spawns the
host with a capturing fake hub, drives a real engine Inactive->Active transition
through the existing ScriptedAlarms harness, and asserts exactly one
TelemetryItem.Alarm carrying the Activated transition.
- VirtualTag_script_log_emits_one_Script_item_with_same_payload: passes a fake hub
via Props + a publisherFactory, drives an evaluator failure, and asserts a
TelemetryItem.Script carrying the SAME ScriptLogEntry instance handed to the
publisher.
To make the VirtualTagActor tap observable, its hub emit moved to the TOP of
PublishLog, before the test-only publisherFactory early-return branch, so the emit
fires on BOTH the production DPS path and the factory path. Production behavior is
unchanged: production passes publisherFactory: null, so it still reaches the DPS
Tell; the emit just moved a couple lines earlier (both are fire-and-forget).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Each of the four telemetry publish seams now also emits its DPS payload into the
node-local ITelemetryLocalHub (Phase 5), leaving the existing DistributedPubSub
publish untouched:
- driver-health AkkaDriverHealthPublisher -> TelemetryItem.Health
- resilience DriverResilienceStatusPublisherService -> TelemetryItem.Resilience
- alerts ScriptedAlarmHostActor + DriverHostActor (native) -> TelemetryItem.Alarm
- script-logs VirtualTagActor + DpsScriptLogPublisher -> TelemetryItem.Script
DI services take the hub as a required ctor param; actors take an optional
nullable hub threaded through their Props and the real spawn sites
(DriverHostActor <- ServiceCollectionExtensions; VirtualTagActor/ScriptedAlarmHostActor
<- DriverHostActor), so existing test Props constructions keep compiling and simply
do not emit. The hub is a no-op until a gRPC client subscribes, so the emit is safe
on every node.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Scripted-alarm condition state lives in LocalDb (Phase 4); the ConfigDb-backed
Ef store + ScriptedAlarmState table are now dead. Removes the test-harness Ef
fallback (a DB-backed node with no store now skips the alarm host), deletes the
store + entity + model config, and drops the table via migration.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
A driver-only node has a null dbFactory but a wired applier; the old combined
guard routed it to the raw-sink fallback that wipes the address space and never
re-materialises. Split the guard so in-hand artifact bytes drive the real
diff-and-apply; a missing artifact abandons the rebuild (keep last-known-good,
#485) instead of wiping.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Nullable ConfigDb factory; UpsertNodeDeploymentState no-ops when absent
(central persists acks from the ApplyAck); scripted-alarm condition state
served from the LocalDb store instead of the ConfigDb-backed Ef store.
Combines Phase-4 Tasks 4 + 6. Removes the interim dbFactory! cast.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The Phase 3 live gate deadlocked every FetchAndCache deploy: DeploymentArtifactGrpcService
gated on Status==Sealed, but central seals a deployment only AFTER every node acks Applied,
and a FetchAndCache node cannot ack until it has fetched the artifact — seal-needs-ack ->
ack-needs-fetch -> fetch-needs-sealed. The node's fetch reached central and passed the
shared-key interceptor, then got a clean NotFound; the #485 apply-failure path correctly
kept last-known-good, but the deploy could never seal.
Direct mode reads the same ArtifactBlob from SQL while the row is still AwaitingApplyAcks,
so the serve path must too. Drop the Sealed gate: serve any deployment whose blob is
non-empty (unknown id / empty blob still collapse to NotFound — existence-hiding + #485).
Access is already gated by the interceptor, so there is no reason to hide a non-sealed
deployment a node is legitimately applying. The Task 2 'non-sealed -> NotFound' test flips
to 'non-sealed with a blob is served'.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 3 Task 6. In FetchAndCache mode Bootstrap() branches to BootstrapFromCache(),
which restores served state from the LocalDb pointer (GetCurrentUnkeyedAsync → set
_currentRevision → ApplyCachedArtifact from the cached bytes) with NO central-SQL read.
Distinct from the Direct-mode TryBootFromCache fallback: reading the cache is NORMAL
operation here so _isRunningFromCache stays false (the node can still fetch new
deploys). An empty OR unreadable cache lands Steady-with-no-revision (the first dispatch
fetches), never Stale — Stale means 'central SQL down, retry it', and FetchAndCache has
no config SQL read to recover. TryRecoverFromStale gains a defensive FetchAndCache guard
(it is unreachable in that mode, since the retry-db timer only starts in Stale).
Proven with a ThrowingDbFactory in every test: reaching Steady + applying a dispatch
proves the boot never touched central SQL. Sabotaging the mode branch (fall through to
the SQL read) reddens all three — test A flips RunningFromCache; B/C enter Stale, which
ignores the dispatch.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 3 Task 5. Under ConfigSource:Mode=FetchAndCache, a DispatchDeployment whose
revision differs from _currentRevision kicks off a gRPC artifact fetch that PipeTo's
its result back to Self as FetchedForApply (never a blocking await in a receive — that
would freeze the mailbox for the fetch deadline). HandleFetchedForApply then applies
synchronously on the actor thread, mirroring ApplyAndAck: null bytes = apply FAILURE
(keep last-known-good, do not advance the revision, ack Failed — #485); non-null bytes
reconcile drivers, rebuild the address space and push subscriptions FROM the bytes in
hand (OpcUaPublishActor never reads central SQL), then cache them. A faulted fetch task
maps to null bytes so it can't escape as an unhandled message.
Extracted ReconcileDriversFromBlob from ReconcileDrivers so Direct-read, FetchAndCache
and boot-from-cache share one reconcile body (the #485 empty guard moves into it);
ApplyCachedArtifact now reuses it instead of duplicating the spawn-plan logic.
DI: DriverHostActor.Props gains fetchAndCacheMode + artifactFetcher (LAST, per the
positional-forwarding warning); Runtime resolves them from IOptions<ConfigSourceOptions>
(fetcher only in FetchAndCache mode); Host registers GrpcDeploymentArtifactFetcher under
hasDriver.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Phase 3 Task 4. GrpcDeploymentArtifactFetcher streams the artifact from the first
configured central endpoint that answers, reassembles the chunks, and verifies
SHA-256(bytes) lowercase-hex == the dispatched RevisionHash (byte-identical to
ConfigComposer's Convert.ToHexStringLower(SHA256.HashData(blob))). Every per-endpoint
problem — RpcException, zero-length stream, or hash mismatch — is logged and the next
endpoint tried; if none yield verified bytes it returns null (apply failure, keep
last-known-good — the #485 contract), never throwing into the actor loop. A
Func<endpoint, client> seam keeps the gRPC boundary out of the unit tests (that is
Task 8's job); the real path caches one h2c GrpcChannel per endpoint.
Files live under the .DeploymentCache namespace (folder Deployment/) to avoid shadowing
the Configuration.Entities.Deployment type in the test assembly — same convention as
LocalDbDeploymentArtifactCache.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW