Commit Graph

121 Commits

Author SHA1 Message Date
Joseph Doherty 2e70ef88aa fix(mesh-phase5): telemetry service — graceful client-disconnect + null-safe required-string mapping
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:02:26 -04:00
Joseph Doherty 50f1620d79 feat(mesh-phase5): fail-closed bearer interceptor for the telemetry stream
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:58:45 -04:00
Joseph Doherty e742fee452 feat(mesh-phase5): node-side TelemetryStreamService (hub -> server-streaming)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:48:06 -04:00
Joseph Doherty 8e5090edf3 feat(mesh-phase5): tap the 4 telemetry publish seams into the local hub (DPS intact)
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
2026-07-23 15:27:29 -04:00
Joseph Doherty d907160747 feat(mesh-phase4): DriverHostActor runs ConfigDb-free (LocalDb alarm store, guarded acks)
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
2026-07-23 12:39:41 -04:00
Joseph Doherty 4f4cdd05ec feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:19:44 -04:00
Joseph Doherty 833e45db9c fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:44:44 -04:00
Joseph Doherty a41582ea6a feat(mesh-phase4): register ConfigDb only on admin-role nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:38:40 -04:00
Joseph Doherty 648f173b18 feat(mesh): FetchAndCache apply path — fetch->cache->apply-from-bytes, null=apply-failure
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
2026-07-22 19:39:26 -04:00
Joseph Doherty 4d88f67c0d feat(mesh): config-serve auth interceptor + dedicated h2c listener (fail-closed bearer)
Phase 3 Task 3. Central serves the deployment artifact over a dedicated h2c
Kestrel listener (ConfigServe:GrpcListenPort), gated by the ConfigServeAuthInterceptor
(shared bearer key, FixedTimeEquals, fail-closed, path-scoped to
/deployment_artifact.v1.DeploymentArtifactService/). The listener block is merged
with the LocalDb-sync listener so a fused admin+driver node re-applies its existing
HTTP surface exactly ONCE and adds both dedicated h2c ports on top — double-binding
would throw 'address already in use'; zero re-binding would silently unbind the
AdminUI behind Traefik. AddGrpc now registers under hasDriver || hasAdmin with both
path-scoped interceptors sharing one pipeline.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:22:55 -04:00
Joseph Doherty 69b697bc3e feat(redundancy): manual failover service — graceful Leave of the driver Primary
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 06:40:58 -04:00
Joseph Doherty f9f1b8fcee fix(localdb): phase-2 live gate — 4 production defects found and fixed
Gate record: docs/plans/2026-07-20-localdb-phase2-live-gate.md.
Checks 1, 2, 5, 6 pass. Checks 3 and 4 are NOT satisfied — the defect they
were meant to confirm turned out to be the opposite of what the plan assumed.

Three defects crash-looped every driver node before check 1 could even run:

1. An empty ServerHistorian:ApiKey kills the host. ServerHistorianOptions-
   Validator exists to turn exactly that class of failure into a named
   OptionsValidationException, but its documented fail tier explicitly excluded
   ApiKey on the reasoning that a keyless client "degrades — the gateway rejects
   calls". It does not: the client validates its own options at construction, so
   the process dies during Akka startup and never makes a call.

2. UseTls disagreeing with the endpoint scheme kills the host too, in both
   directions (both messages confirmed in the shipped client assembly). Moving an
   endpoint from https to http without clearing UseTls is an ordinary migration
   slip.

3. Plaintext h2c was UNREACHABLE. HistorianGatewayClientAdapter forwarded the
   TLS-only options unconditionally, and AllowUntrustedServerCertificate defaults
   to false, so it always sent RequireCertificateValidation=true — which the
   client rejects outright when UseTls=false. Every http:// deployment crashed,
   though the scheme is documented as the supported way to select h2c, and the
   only workaround was to assert a certificate posture for a connection that has
   no certificate.

The fourth was the blocker, and it is Phase 2's own:

4. The drain gate deferred to a Primary that cannot deliver. Redundancy roles are
   elected CLUSTER-WIDE; the alarm queue is PAIR-LOCAL. On the rig the elected
   driver Primary is central-1 — it carries the driver Akka role, replicates
   nobody's LocalDb and does not even run the alarm historian — so every driver
   node logged "Historian drain suspended", including the two site-b nodes that
   have no peer at all. Nothing drained anywhere, where before Phase 2 it drained
   fine. The cost is not a duplicate; it is the buffer growing to the capacity
   wall and evicting the audit trail it exists to protect.

   Fixed in three layers: a separate ShouldDrainAlarmHistory policy (unknown role
   drains; the two gates now deliberately disagree, and a test pins that); peer-
   host matching in DriverHostActor so a node stands down only for a Primary
   holding its rows; and AddAlarmHistorian short-circuiting the gate when
   replication is unconfigured — testing BOTH Replication:PeerAddress and
   SyncListenPort, since only the dialing half sets the former while both halves
   share the queue.

   Every one of these follows from the asymmetry: a false allow costs a duplicate
   row, which at-least-once delivery already accepts and payload-hash ids
   collapse; a false deny loses data silently.

A third vacuous test, caught by the same delete-the-guard discipline: the
role-view tests stayed green with the guard removed, because AwaitAssert polls
until an assertion passes and the assertion was "reads open" — which is the
SEEDED value, satisfied at the first poll before the actor processed anything.
They now assert the sequence of published values through a recording view; the
control then goes red for exactly the cases that matter.

Migration evidence: 11 legacy rows across two deliberately overlapping files
converged to exactly 9 identical rows on both nodes, proving D-6's payload-hash
identity on real nodes rather than in a fixture.

Open design fork, recorded in the gate doc rather than decided here: a pair
cannot currently identify its own Primary, so both halves drain. Safe in every
topology — nothing loses data — but the gate's de-duplication benefit is
unrealised until roles are scoped per pair.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 06:13:01 -04:00
Joseph Doherty 2e4ccf7fe9 chore(localdb): phase-2 DoD sweep
Build: 0 errors solution-wide, and 0 warnings from every project this branch
touches. The ~816 solution-wide warnings are pre-existing xUnit1051 /
OTOPCUA0001 / CS86xx in untouched driver + client test projects.

Tests: full solution run compared against a full run on a detached worktree at
the pre-branch baseline 2e46d054. The two failure SETS are identical -- all 13
tests, same names, zero new failures. Net +26 tests: +3 Core.AlarmHistorian
(drain gate), +6 Runtime (role view), +17 Host.IntegrationTests (migrator +
convergence). Set comparison rather than counts, because the suite carries
standing environment- and load-dependent failures a count would hide.

The greps found real drift Task 6 missed -- eight live sites still naming the
deleted SqliteStoreAndForwardSink, including the AdminUI /alarms/historian
panel text, which is user-visible, and a <see cref> in HistorianAdapterActor
that resolved to nothing without warning. All repointed at
LocalDbStoreAndForwardSink; CLAUDE.md's alarm-history paragraph now also
records the LocalDb buffer and the primary-gated drain, and drops DatabasePath
from the knob list. docs/AlarmTracking.md still promised an
AlarmHistorianOptions.Validate() startup warning for a relative DatabasePath
and an empty SharedSecret; both branches are gone, so it now says so.

Code references to AlarmHistorian:DatabasePath reduce to exactly two intentional
ones: AlarmSfLegacyMigrator.LegacyPathKey and its test. No `new SqliteConnection`
remains anywhere in Core.AlarmHistorian.

Recon doc gains the durable verification record: guard-deletion evidence for
both vacuous passes, the two exact-set replicated-table pins (both assert set
equality, so an added or a dropped registration fails), and the baseline test
comparison with a per-failure account of why each of the 13 is not this
branch's.

Stops here per the plan. Task 8's live gate needs explicit go-ahead; nothing on
this branch is to be merged.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 05:03:57 -04:00
Joseph Doherty af545efdf5 feat(localdb): one-time alarm-historian.db migrator
Copies the pre-consolidation store-and-forward queue into the consolidated
database on first boot, then renames the legacy file aside. Rows are in that
file precisely because the historian could not be reached, so dropping them
on upgrade would discard exactly the alarm audit trail the queue exists to
protect.

Runs last in OnReady, after every RegisterReplicated call. It is the only
thing in OnReady that writes rows, and capture is trigger-based: a migration
that ran before registration would recover the backlog locally and never
replicate a line of it, silently and permanently.

Ids are derived from the payload rather than the plan's mig-{node}-{legacyId}
scheme. Node-prefixing solves the collision the legacy AUTOINCREMENT key
would cause -- node A's row 7 and node B's row 7 are different alarms -- but
it preserves a duplication that should be collapsed instead. A warm pair's
two legacy files OVERLAP: HistorianAdapterActor default-writes while its
redundancy role is unknown, so both nodes accepted the same transitions
during every boot window. Prefixed ids would carry those duplicates into the
merged buffer forever; equal-payload ids converge them. The same property
makes a crash between commit and rename harmless under INSERT OR IGNORE.

OnReady now takes IConfiguration rather than offering an overload that skips
the migration. A wiring mistake that silently discarded a node's undelivered
alarm history is not a mistake worth making possible.

The copy is restricted to the columns the legacy table actually has. Naming
a column an older build never wrote throws "no such column", which would
discard every row in the table rather than the one field.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 04:31:10 -04:00
Joseph Doherty 71379816e7 feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover)
Moves the alarm store-and-forward buffer out of its own alarm-historian.db
and into the node's consolidated LocalDb, where it replicates to the
redundant pair peer. A node that dies holding undelivered alarm history no
longer takes it to the grave.

Tasks 2 and 3 land together, as the plan anticipated. They are not separable:
the gate is a constructor argument of the rewritten sink, and a commit that
replicated the queue without gating the drain would be a commit in which both
nodes of a pair deliver every alarm event, continuously.

That drain gate is the load-bearing part of the change, and the recon
explains why it is new work rather than a refinement. Exactly-once delivery
across a pair is enforced today on the ENQUEUE side, by
HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is
empty and its ungated drain has nothing to send. Replicating the table
destroys that invariant.

The gate is a Func<bool> the caller supplies, because the drain runs on a
timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot
reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new
IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate
verdict to -- the same verdict the inbound-write and native-ack gates use, so
there is no second notion of am-I-the-Primary to drift.

Two failure modes are deliberately closed:

- The view is seeded OPEN, matching the policy's own answer for an unknown
  role with no driver peer. A deployment that runs no redundancy never
  publishes to it, and defaulting closed would silently stop its alarm
  history forever.

- A gate that throws is read as not-now, never as permission, and a closed
  gate reports the new HistorianDrainState.NotPrimary rather than Idle. A
  Secondary's rising queue is supposed to look different from a stalled
  drain, and if BOTH nodes report NotPrimary the pair is misconfigured and
  says so instead of quietly filling toward the capacity ceiling.

Row ids are a hash of the payload rather than fresh GUIDs. Both adapters
accept the same fanned transition in the window before the first redundancy
snapshot arrives, and under last-writer-wins an equal key converges those two
accepts into one row instead of duplicating them.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 04:13:00 -04:00
Joseph Doherty 8a9cb40a72 feat(localdb): alarm_sf_events replicated table
Adds the alarm store-and-forward buffer to the consolidated LocalDb file and
registers it for replication, so a node that dies with undelivered alarm
history no longer takes it to the grave.

The table keeps the legacy queue's shape rather than the status column the
plan sketched. An acknowledged row is deleted, not marked delivered: that
needs no second sweeper to keep the table bounded, leaves the capacity
semantics untouched, and the replication engine carries the delete as a
tombstone so the peer drops its copy anyway -- which is what the status
column was for. last_error is retained because it is the only operator-facing
record of why a row was dead-lettered.

The primary key is app-minted TEXT. The legacy AUTOINCREMENT RowId cannot
replicate under last-writer-wins: two nodes would independently allocate
rowid 7 to different alarms and silently overwrite each other. The drain
index therefore orders by enqueued_at_utc rather than insertion order, with
id as a tiebreak so the ordering is total.

Tables are created unconditionally, independent of AlarmHistorian:Enabled.
An empty registered table costs three triggers; creating it lazily would mean
a node that enables the historian later writes rows before its capture
triggers exist, which is exactly the silent-loss shape OnReady's ordering
comment warns about.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:58:50 -04:00
Joseph Doherty ce9fa07ff8 fix(localdb): re-bind ASPNETCORE_HTTP_PORTS surface, not just ASPNETCORE_URLS
The Kestrel re-bind read only urls/ASPNETCORE_URLS. The aspnet:8.0+ base images
set ASPNETCORE_HTTP_PORTS=8080 (all interfaces) as the CONTAINER default in place
of ASPNETCORE_URLS, so a driver node that never sets URLS fell straight through to
Kestrel's localhost:5000 default — silently moving its health/metrics surface to
loopback when the sync listener took over Kestrel. Now falls through URLS →
HTTP_PORTS/HTTPS_PORTS (wildcard, all-interfaces) → localhost:5000, mirroring
Kestrel's own source precedence. Found on the docker-dev live gate (central nodes
were fine — they set ASPNETCORE_URLS=9000 explicitly).
2026-07-20 22:25:31 -04:00
Joseph Doherty b350fba233 feat(localdb): replication health check + meter export
LocalDbReplicationHealthCheck reports Healthy when replication is default-OFF or
LocalDb is absent (admin-only graphs) - so a plain node is never degraded by it -
Degraded when configured-but-disconnected, connected-with-unknown-backlog (a failed
oplog poll must not read as zero), or connected-with-backlog past the threshold.
Never Unhealthy: a replication fault does not stop the node serving its address
space. Pure decision core is table-tested; registered unconditionally via a factory
so AddOtOpcUaHealth keeps its no-arg signature and resolves ISyncStatus optionally.

Adds LocalDbMetrics.MeterName to the observability allowlist. o.Meters is a strict
allowlist with no wildcard, so the localdb.sync.* / localdb.oplog.depth series were
otherwise silently absent from /metrics - the omission a ScadaBridge live gate
caught.
2026-07-20 21:48:47 -04:00
Joseph Doherty 1becf59168 feat(localdb): DriverHostActor stores applied artifacts in the pair-local cache
New Props/ctor parameter appended LAST at all three positions - the forwarding
list inside Props.Create is positional and compiles into an expression tree, so a
mis-ordered argument is usually type-compatible and binds the wrong dependency at
runtime. All 26 existing test construction sites use named arguments and needed no
change.

ReconcileDrivers now returns the blob it loaded. It catches its own DB failures and
returns without rethrowing, so ApplyAndAck can reach its success path having spawned
zero drivers; caching on 'the apply succeeded' would persist an empty artifact as
last-known-good and boot the node into an empty address space during the next
outage. Verified: weakening the guard to null-only turns EmptyArtifact_IsNotCached
red.

The store runs in its own try/catch because an Applied ACK has already been sent by
then - an escaping exception would land in ApplyAndAck's catch and emit a second,
contradictory Failed ACK for a deployment the fleet believes is live.
2026-07-20 11:06:57 -04:00
Joseph Doherty e771a11a30 fix(localdb): rename Runtime.Deployment namespace to Runtime.DeploymentCache
The Runtime.Deployment namespace shadowed the Deployment EF entity type for
every file under ZB.MOM.WW.OtOpcUa.Runtime.Tests.*: C# name lookup walks the
enclosing namespace chain and finds the namespace before a using-imported type,
so 19 pre-existing call sites failed with CS0118. Caught only by a full-solution
build - the Host and its own tests compiled fine.
2026-07-20 10:42:22 -04:00
Joseph Doherty b9ddf20edd feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort 2026-07-20 10:39:34 -04:00
Joseph Doherty 6aff9a8332 feat(localdb): wire AddOtOpcUaLocalDb into the driver role 2026-07-20 10:35:02 -04:00
Joseph Doherty 3d29be834e feat(localdb): fail-closed bearer interceptor for the sync endpoint 2026-07-20 10:28:39 -04:00
Joseph Doherty 3b65c24bfb feat(localdb): deployment-cache schema + OnReady registration 2026-07-20 10:28:39 -04:00
Joseph Doherty 44255fcb91 build(localdb): reference ZB.MOM.WW.LocalDb 0.1.1 + Grpc.AspNetCore 2026-07-20 10:21:47 -04:00
Joseph Doherty 2254ae3dea fix(secrets): consume Secrets 0.2.2 - clustered-secrets DI deadlock fixed upstream
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 11m7s
Bumps the four ZB.MOM.WW.Secrets pins 0.2.1 -> 0.2.2 (closes the OtOpcUa side
of scadaproj#1, tracked here as #482). 0.2.1's Akka replicator deadlocked any
hosted process at startup when Secrets:Replication:Enabled was true: the
package's DI graph closed a circular singleton dependency through factory
lambdas (store decorator -> replicator -> actor provider -> cache invalidator
-> resolver -> store), which MS.DI's StackGuard turns into a silent
cross-thread call-site-lock deadlock. 0.2.2 defers the invalidator edge to
first eviction. The flag stays default-false; enabling remains a
per-environment decision.

The_startup_hook_actually_creates_the_replication_actor is now a real test:
SecretReplicationStarter's docs had promised it since the adoption, and the
upstream fix finally makes a provider-based resolve runnable - container built
exactly as the host does, hook started under a watchdog, replication actor
proven to exist by ActorSelection on a self-joined single-node cluster (no
TestKit needed, which matters because Akka.TestKit.Xunit2 is xunit-v2-only and
this project is on xunit.v3). Also corrects the stale rationale that blamed
the old hang on DistributedPubSub needing a joined cluster - the actor
constructor was never reached; it was the DI cycle.

Verified: SecretsReplicationRegistrationTests 8/8 on the 0.2.2 feed packages;
full slnx build 0 errors; the 2-node Akka live convergence gate re-run against
the published 0.2.2 packages passes 6/6 (write->peer, tombstone propagation
without resurrection, delete visibility through the resolver cache, reverse
direction, wrong-KEK fail-closed).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 15:03:35 -04:00
Joseph Doherty 3336ec08c7 feat(secrets): opt-in Akka cluster secret replication (default OFF; upstream blocker documented)
Routes the host's secrets registration through a new AddOtOpcUaSecrets extension
that gates the ISecretStore implementation on Secrets:Replication:Enabled.

Opt-in gate (default FALSE)
  This call decides which ISecretStore every node resolves — including driver-role
  nodes with no auth/AdminUI, where a wrong store surfaces as drivers failing to
  open sessions rather than as a failing test. With the flag false the wiring is
  the pre-existing AddZbSecrets(config, "Secrets") call, unchanged, so current
  behavior is byte-identical. With it true, AddZbSecretsAkkaReplication replaces
  that call (it invokes AddZbSecrets internally; calling both would double-register).

  Extracted to a named extension specifically so the registration is testable:
  Program.cs is top-level statements and cannot be exercised by a container test,
  which is how a "registered but never resolvable" defect ships unnoticed.

Serializer HOCON
  AkkaSecretsReplication.SerializationConfig is merged into the ActorSystem config
  inside the AddAkka configurator, conditionally on the same gate — a non-replicating
  node carries no bindings for messages it will never see. Merged via
  AddHocon(..., HoconAddMode.Append), Akka.Hosting's fallback merge and the same mode
  the existing base-config merge uses; a raw Config.WithFallback would fight the
  builder's own assembly.

Lazy-actor mitigation
  The replication actor is created lazily on first ISecretStore resolution, so a node
  that never touches a secret would never announce a manifest and would silently never
  converge. SecretReplicationStarter (IHostedService) resolves the store once at
  startup to make participation unconditional.

KNOWN BLOCKER — replication is currently NON-FUNCTIONAL; do not enable
  ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 never binds its own ISecretReplicator.
  AddZbSecretsAkkaReplication calls AddZbSecrets FIRST, which does
  TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>(); the package's own
  TryAddSingleton<ISecretReplicator>(AkkaSecretReplicator) that follows is therefore
  a no-op. Verified empirically in a built container: with Enabled=true,
  ISecretReplicator resolves to NoOpSecretReplicator, so ReplicatingSecretStore
  publishes into a sink and no actor is ever spawned.

  Consequence: the startup hook cannot create the actor, and the test asserting it
  does is committed Skipped with the evidence. Not worked around here — the fix
  belongs upstream (AddSingleton, or register before calling AddZbSecrets).
  Because the flag defaults false, this commit is inert in production.

Tests: SecretsReplicationRegistrationTests (new) — disabled path resolves plain
SqliteSecretStore and needs no ActorSystem; enabled path resolves
ReplicatingSecretStore AND the undecorated concrete SqliteSecretStore the decorator
is built from (the exact registration gap that shipped once); startup hook registered
only when enabled. Red before wiring (4 assertion failures), green after: 6 pass,
1 skipped (blocker above).

Build: 861 warnings / 0 errors, unchanged from baseline (full --no-incremental A/B).
Host.IntegrationTests: 123 pass, 6 skip, 1 fail — AbCip_Green_AgainstSim, verified
pre-existing on the stashed tree (fixture-gated).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 11:15:03 -04:00
Joseph Doherty e27c19c49d feat(secrets): add ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 package reference 2026-07-18 05:35:12 -04:00
Joseph Doherty 9bb237b794 feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared
ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The
driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution
is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity)
rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its
re-resolve-on-reconnect behavior. Both consumers (username Password and the
certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved
connect-scoped options; _options stays raw (secret: refs intact), no long-lived
plaintext field.

Scope corrections vs the plan (verified against v3): the probe is unauthenticated
GetEndpoints-only and never reads either credential, so it is NOT a resolution site
(comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted
to sealed record for the with-expression (no positional params → identical JSON; no
reference-equality/dict-key/ToString-log usages → no behavior/leak change).

ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap
(real resolver from DI); NullSecretResolver null-object backs test/parse paths only,
fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
2026-07-16 18:14:12 -04:00
Joseph Doherty 1424a21419 feat(secrets): G-2a secret: arm on GalaxySecretRef via ISecretResolver (Task 7)
Add a secret:NAME arm to GalaxySecretRef.ResolveApiKey that resolves the Galaxy
gateway API key through the shared ISecretResolver — fail-closed if the secret is
absent (never falls through to the cleartext literal arm), retiring the dev:/literal
in-DB path for production. Because GetAsync is async the method becomes
ResolveApiKeyAsync; the await cascade threads ISecretResolver by ctor injection into
GalaxyDriver + GalaxyDriverBrowser and (since GalaxyDriver is built by a static
factory closure, not DI) through GalaxyDriverFactoryExtensions + DriverFactoryBootstrap
(which pulls the real resolver from the service provider — registered unconditionally
in Slice 1). A NullSecretResolver null-object backs the parse-only/test paths only;
the runtime path always gets the real resolver (verified end-to-end).

TDD: 3 new secret:-arm tests (resolve / fail-closed-on-absent / no-literal-warning)
RED without the arm, GREEN with it; 338 Galaxy tests pass; no sync-over-async.
2026-07-16 17:57:04 -04:00
Joseph Doherty 73d8439412 docs(secrets): G-4 ${secret:} config delivery convention + fail-closed proof (Task 4)
OtOpcUa commits no plaintext secrets — the only secret-shaped committed value is
ServerHistorian:ApiKey='' (empty, disabled by default). Because the pre-host expander
is fail-closed AND section-agnostic, hard-committing a ${secret:} token would break
every dev/CI/integration boot for zero at-rest benefit. Instead document the token
delivery convention for all five config secrets (jwt/ldap/deploy/configdb/historian)
in docs/security.md and note the token option in the ServerHistorian comments.

Mechanism proven live: unseeded token boot fails with SecretNotFoundException at
pre-host expansion; seeded token resolves and boot proceeds. Behavior-neutral
(comment + doc only).
2026-07-16 17:14:32 -04:00
Joseph Doherty 8843418c54 feat(secrets): register AddZbSecrets unconditionally on the host (Task 3)
Runtime ISecretResolver must be present on every clustered node regardless of
role: driver-only nodes resolve Layer-B DriverConfig secret: refs but have no
auth/DP/AdminUI (all admin-role-gated). Placed in the unconditional flow next to
AddOtOpcUaHealth(), outside both role blocks. Data Protection left untouched.
2026-07-16 17:07:41 -04:00
Joseph Doherty 772d3a5f34 feat(secrets): add ZB.MOM.WW.Secrets refs + pre-host ${secret:} expander (Tasks 1-2)
Adopt the ZB.MOM.WW.Secrets library (0.1.2, Gitea feed) into OtOpcUa under CPM:
- Directory.Packages.props + NuGet.config source mapping for the three packages
- Host/AdminUI/Driver.Galaxy.Contracts/Driver.OpcUaClient package references
- Layer-A pre-host ${secret:} config expander in Host/Program.cs, placed after all
  config providers assemble and before the first config read (mechanism only,
  behavior-neutral no-op until Task 4 introduces tokens)
- Secrets section in appsettings.json (env-var KEK, SQLite store, 30s cache TTL)

Mechanism mirrors the proven HistorianGateway adoption. KEK never committed.
2026-07-16 17:03:16 -04:00
Joseph Doherty 53d222e0f7 feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live
values) plus the host seam that feeds it:

- IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation.
- CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate +
  dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission,
  Good recovery; reuses the T0-4 CalculationEvaluator.
- DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver,
  registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged
  → OnDependencyValue; re-registers on every apply, torn down with the driver.
- Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test
  csproj references the driver so the bin-scan discovers the factory.
- DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's
  delivered TagConfig so the host-blind driver can compile it.
- DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId
  existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges).
- Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer,
  read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a
  Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:29:37 -04:00
Joseph Doherty b0eb653bad fix(redundancy): 2-node SBR exit-and-rejoin recovery — watchdog + restart supervision + both-node seeds (#459)
v2-ci / unit-tests (pull_request) Failing after 12m9s
v2-ci / build (pull_request) Successful in 5m12s
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.

Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.

Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
  ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
  process exits (supervisor restarts it) instead of idling with a dead actor system.
  Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
  both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
  fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
  requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
  non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
  ANY single loss needs 3+ members.

Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
2026-07-15 10:52:36 -04:00
Joseph Doherty 56ddf45e1c chore(core): flatten Historian sub-namespace to Core.Abstractions root
v2-ci / build (pull_request) Successful in 4m32s
v2-ci / unit-tests (pull_request) Failing after 10m2s
Four continuous-historization types (IHistorizationOutbox, HistorizationOutboxEntry,
IHistorianValueWriter/HistorizationValue, HistorizationCommitMode) drifted into a
ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian sub-namespace while the other six files
in the same folder correctly use the root namespace. This violated decision #59's
flat-public-surface rule and left InterfaceIndependenceTests.AllPublicTypes_LiveInRootNamespace
red on master (pre-existing, predates round-2 remediation).

Move all four to the root namespace and drop/retarget the now-redundant
'using ...Core.Abstractions.Historian;' in the ~11 consumers. No behavioral change.

Verified: Core.Abstractions.Tests 129/129 (was 128/129), Runtime.Tests Historian+DI 72/72,
Gateway driver unit 102/102, full solution build 0 errors.
2026-07-13 18:29:01 -04:00
Joseph Doherty 304442dba4 feat(runtime): ServerHistorian HistoryRead parallelism/limiter/deadline knobs + node-manager wiring (03/S3) 2026-07-13 12:03:27 -04:00
Joseph Doherty 0c785415a2 feat(host): LDAP directory-outage circuit — fail-fast denial during sustained outage (03/S2) 2026-07-13 11:56:45 -04:00
Joseph Doherty 21abe42a90 feat(host): hard wall-clock bound + bounded concurrency on OPC UA LDAP authentication (03/S2) 2026-07-13 11:52:10 -04:00
Joseph Doherty 09faa05f06 feat(security): LDAP auth timeout/concurrency/outage-circuit options; project ConnectionTimeoutMs into the shared library (03/S2) 2026-07-13 11:38:37 -04:00
Joseph Doherty 1a698cbb97 Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s
Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
2026-07-13 11:29:32 -04:00
Joseph Doherty 7fc3b19961 docs: R2-11 TagConfig consolidation + strictness rollout decisions 2026-07-13 11:24:39 -04:00
Joseph Doherty 12b2ce078b fix(r2-10): register the tracker's production reader on driver nodes + wiring guard 2026-07-13 10:42:18 -04:00
Joseph Doherty 37f180c5a3 fix(r2-10): periodic DPS publisher for tracker snapshots 2026-07-13 10:40:54 -04:00
Joseph Doherty 901d4e6ed3 Merge R2-03 Virtual-Tag failure quality (arch-review round 2) [PR #430]
v2-ci / build (push) Successful in 4m2s
v2-ci / unit-tests (push) Failing after 8m20s
Findings 02/S13 (VT failure/timeout degrades node to Bad quality, no stale-Good
forever), 02/S12 (evaluator retry-once on disposed-race), 02/P7 (compile-cache
clear gated on changed expression set). T11/T12 deferred. STATUS.md conflict
resolved additively. Build clean.
2026-07-13 10:20:43 -04:00
Joseph Doherty ef00d6e5aa fix(host): retry VT evaluation once on ObjectDisposedException from the apply-boundary cache clear (02/S12) 2026-07-13 10:12:59 -04:00
Joseph Doherty e748f929c8 fix(r2-06): wire AddValidatedOptions<ServerHistorianOptions> + alarm-only-mode startup warning in Program.cs (06/S-11) 2026-07-13 09:55:25 -04:00
Joseph Doherty 5369994e57 fix(r2-06): GREEN — ServerHistorianOptionsValidator (consumer-gated fail-fast on bad Endpoint) (06/S-11) 2026-07-13 09:54:37 -04:00
Joseph Doherty 45455b7677 Merge branch 'fix/archreview-crit2-vt-timeout' 2026-07-09 06:07:33 -04:00
Joseph Doherty 75403caa1a feat(archreview #13): apply per-instance ResilienceConfig from the deploy artifact
The DriverInstance.ResilienceConfig column was authored in AdminUI, persisted to the
entity, and serialized into the deployment artifact by ConfigComposer — but the runtime
read path dropped it: DriverInstanceSpec didn't carry it and the invoker factory always
passed null, so every driver got tier defaults regardless of its configured overrides
(a silent dead-config gap — #10's residual sub-finding).

Read-path plumbing (write side was already complete):
- DriverInstanceSpec gains ResilienceConfig; DeploymentArtifact.TryReadSpec reads the column.
- IDriverCapabilityInvokerFactory.Create takes resilienceConfigJson; DriverHostActor.SpawnChild
  threads spec.ResilienceConfig; the concrete factory parses it (ParseOrDefaults, layering on the
  tier), logs any parse diagnostic (never throws), and builds the invoker with the merged options.
- Invalidate-on-change: the pipeline cache keys on (instance, host, capability) and ignores options
  on a hit, so Create() now Invalidate()s the instance's cached pipelines first (no-op on first
  spawn) — a respawn with changed options rebuilds them.
- DriverSpawnPlanner treats a ResilienceConfig change as a stop+respawn (the invoker/options are
  bound to the child at spawn); a pure DriverConfig change stays an in-place delta (no reconnect).
- Host DI passes a logger to the factory for the parse diagnostic.

Verification (deterministic): factory Create applies a retryCount:0 override to actual execution
(control test proves tier default retries), invalidates the instance's cache on re-create (scoped —
sibling survives), malformed config logs+falls-back; planner respawns on ResilienceConfig change
(incl null→json) and stays delta on a pure config change; artifact parse carries/omits the column.
Core.Tests 243 (+5), Runtime.Tests 363 (+5), Host builds clean.
2026-07-08 22:48:17 -04:00