Commit Graph

589 Commits

Author SHA1 Message Date
Joseph Doherty e62b4a5baf chore(secrets): release family 0.5.0 (FallbackEndpoints hub failover)
Bump ZB.MOM.WW.Secrets family 0.4.1 -> 0.5.0 and date the CHANGELOG
entry. All 6 packages (Abstractions, Secrets, Replicator.SqlServer,
Replicator.AkkaDotNet, Replicator.Grpc, Ui) packed and pushed to the
dohertj2-gitea feed; restore-verified from a scratch consumer with a
fresh package cache. Suite 470 passed / 0 failed / 15 skipped.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:51:52 -04:00
Joseph Doherty 7a2980e2f1 fix(grpc): keep the RpcException off the failover warning so no sink can render Status.Detail
Review follow-ups: the warning now carries only the two endpoint URIs and the
status code — attaching the exception object would let any real sink render
its ToString(), which for RpcException includes server-authored Status.Detail.
The leak pin test now also asserts no exception rides on the warning, via a
new WarningExceptions capture on RecordingLogger. Plus two cosmetic nits:
unified ThrowIfNull idiom, documented that FallbackEndpoints mutation after
Validate is unsupported.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:49:45 -04:00
Joseph Doherty 0d4978953f feat(secrets-grpc): FallbackEndpoints + sticky failover hub reader (scadaproj#4)
SecretsGrpcHubClientOptions gains a getter-only FallbackEndpoints list:
additional hub addresses tried in order when a call fails with a
transport-level RpcException. Validation fails startup on blank/relative
entries and on any duplicate of the primary or another fallback (a
duplicated endpoint silently halves the configured failover).

New FailoverSecretsHubReader composes one GrpcSecretsHubClient per
endpoint: the answering endpoint is sticky (a sweep is GetManifest + N
GetSecrets batches — without stickiness every call after a primary
outage re-pays a full CallDeadline before reaching the healthy
fallback), rotation wraps so a recovered primary is retried when the
stuck-to endpoint fails, only RpcException triggers failover
(cancellation and defects propagate untouched), and when every endpoint
fails the last RpcException is rethrown so the sweep degrades to
last-known-good exactly as today. Failover warnings name only the two
endpoint URIs and the status code — never any credential material.

DI: zero fallbacks keeps the registration graph byte-identical to 0.4.x
(single keyed channel under ChannelServiceKey, plain
GrpcSecretsHubClient); with fallbacks, one container-owned keyed channel
per endpoint (fallback i under "{ChannelServiceKey}:fallback:{i}") wired
into the failover reader. GrpcSecretsHubClient itself is unchanged.

Safe only when every listed endpoint serves the SAME store — for
ScadaBridge, the central pair over one shared SQL Server store; the
options doc and CHANGELOG carry the warning.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:37:22 -04:00
Joseph Doherty 7df05ccc27 docs(plans): central shared SQL secrets store + hub endpoint failover (scadaproj#4)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:27:28 -04:00
Joseph Doherty 294eb163cd docs(glauth): keep the verify ldapsearch continuation intact after the SEC-36 comment scrub 2026-08-07 09:18:47 -04:00
Joseph Doherty aada53b27a sec(glauth): rotate serviceaccount passsha256; scrub retired plaintext from comments (mxaccessgw SEC-36) 2026-08-07 09:18:05 -04:00
Joseph Doherty 5198d3dc7f docs(secrets): close out scadaproj#3 — gRPC hub shipped 0.4.1, both live gates PASS; residual filed as #4
Task 9 (closeout) of the Secrets gRPC-hub program. Bookkeeping only; no code.

- clustered-secrets.md, Production enablement: the gRPC hub is no longer
  pending. It is BUILT, SHIPPED (0.4.1) and LIVE-GATED PASS 4/4 (ScadaBridge
  main @ f6c3f7c5, docs/plans/2026-08-07-secrets-grpc-hub-live-gate.md), so the
  "until that ships, ScadaBridge replication stays off" line is replaced with
  the truth: the code path is merged and gated, and production enablement is a
  separate operator action behind a still-default-off flag. OtOpcUa's
  pair-local topology is recorded as live-gated (lmxopcua master @ 1bd79c8e)
  rather than pending re-gate. Rotation fan-out corrected: on ScadaBridge it is
  BOTH central nodes plus both nodes of every site pair, because each central
  node has its own independent store.

- components/secrets/GAPS.md: new "gRPC hub delivered" section recording the
  0.4.0/0.4.1 publish, both gates with SHAs and gate-doc paths, and the
  residual. Existing narrative left intact and amended, not rewritten.

- CLAUDE.md Secrets row: lib version 0.3.0 -> 0.4.1 and the app-pin claim
  corrected (ScadaBridge pins 0.4.1 for the Grpc replicator; the other three
  still pin 0.2.3); the "production topologies DECIDED" region now records
  delivery, both gates, default-off, and the residual.

- Plan ledger: task 9 completed.

Residual carried forward as scadaproj#4 — ScadaBridge's central pair does not
converge with itself (central-b served an authenticated but EMPTY manifest all
gate while central-a held every secret) and the site client dials a single
endpoint; these are one design question, since failover to an empty hub would
succeed and silently stop convergence. scadaproj#3 is closed.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:00:11 -04:00
Joseph Doherty c86cead5c2 fix(secrets): rate-limited denial warning on the Grpc hub (live-gate finding) — 0.4.1
The ScadaBridge live gate (check 4) found SecretsHubAuthInterceptor denying
correctly but logging nothing: the only trace of a refusal was a framework
Grpc.AspNetCore.Server Information line. A follower whose bearer token was
mis-rotated therefore stopped converging silently, with the hub showing it
only at INF.

Every denial is now counted and summarised in a Warning emitted on the first
denial after startup or after a 60-second quiet window, carrying the denials
since the previous summary plus a breakdown by cause. A single probe is
visible immediately; a sustained flood is bounded to one line per window,
which is what makes warning safe at all — the endpoint is unauthenticated by
construction, so a per-denial warning would be an unbounded log write driven
by an unauthenticated caller. Counters are Interlocked and the emit is
decided by a compare-exchange on the last-emit timestamp, so exactly one
thread emits per window and a denial counted after the drain carries into
the next summary rather than being lost.

The cause breakdown is server-side diagnostics only. Denied() is untouched:
every rejection still returns the identical uniform Unauthenticated status,
the comparison is still constant-time, and no token material appears in any
log line. The existing warn-once-on-unconfigured-token signal is unchanged
and still emitted exactly once; denials in that state count toward the new
summary's "unconfigured" category.

TimeProvider is an optional default-valued parameter rather than a second
overload — the type must keep exactly one public constructor or the gRPC
interceptor activator silently stops invoking the auth gate.

Two existing tests scoped their assertions to the configuration warning,
since both kinds now share a category: the new line is deliberate behavior,
not drift.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 08:26:49 -04:00
Joseph Doherty 321ab22111 chore(secrets): 0.4.0 — ship ZB.MOM.WW.Secrets.Replicator.Grpc (pull-only central hub)
Bumps the ZB.MOM.WW.Secrets family from 0.3.0 to 0.4.0 and adds the new
ZB.MOM.WW.Secrets.Replicator.Grpc package to the packable set. Publishes
all six 0.4.0 nupkgs to the dohertj2-gitea feed and restore-verifies the
new package resolves from it in a fresh --packages directory. Existing
packages are behaviorally unchanged — no breaking changes.

Adds ZB.MOM.WW.Secrets/CHANGELOG.md (none existed previously) to record
this and prior releases, and lists the new package in README.md's
Packages table. Also includes the controller's pending ledger updates
for docs/plans/2026-08-07-secrets-grpc-hub-implementation.md (Tasks
1-4 and 8 marked completed).

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:57:30 -04:00
Joseph Doherty 5f81e65684 fix(secrets): route the unset-token harness through the real hub composition (review drift-risk)
An internal validateEagerly seam on AddZbSecretsGrpcHub replaces the harness's
hand-copied registration fragment, so the deny-all second-layer tests exercise
the production wiring and cannot drift from it.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:52:26 -04:00
Joseph Doherty 3e3748b985 test(secrets): Grpc hub end-to-end integration suite + pull-only contract pin
Adds the end-to-end layer the unit suites structurally cannot reach: a real
in-process ASP.NET Core host serving SecretsHubService through
AddZbSecretsGrpcHub/MapZbSecretsHub, a follower built by the shipped
AddZbSecretsGrpcHubClient, a real gRPC call between them, and real SQLite
stores on both sides. The class of defect this catches is pieces that are each
individually correct and are never connected to one another — for instance the
interceptor's warn-once, which only holds if the container really hands gRPC
the singleton it registered.

Covered: byte-identical convergence over the wire (every ciphertext/nonce/tag/
wrapped-DEK byte, kek_id, revision, both timestamps INCLUDING their offsets,
across a tombstone, a nulls-everywhere row and an empty-string-description row);
delete propagation; a hub unreachable at follower boot leaving the last-known-
good store intact and converging once it answers; the auth negatives fail-closed
across the wire with one uniform denial detail; the per-request name cap as
ResourceExhausted; and an RpcException from the call invoker escaping the hub
client unchanged, so a transport fault stays distinguishable from a parse fault.

The credential-hygiene assertions are made against logs captured at Trace on
BOTH hosts, so "no token material ever reaches a sink" is asserted at the most
verbose setting a deployment could switch on.

SecretsHubContractPinTests reads the generated descriptor — not the .proto text
— and pins the RPC set to exactly {GetManifest, GetSecrets}, plus unary-only and
the package-qualified service name. The hub is pull-only by construction:
updates originate at central only, and the absence of a write RPC is what
enforces that, not authorization a mis-wired follower may hold more of than
intended. A third RPC silently retires the guarantee, so the failure message
says so.

Microsoft.AspNetCore.TestHost pinned at 10.0.7 — the version ZB.MOM.WW.LocalDb,
OtOpcUa and ScadaBridge already pin, matching the Microsoft.Extensions.* line.

Full suite: 436 passed / 0 failed / 15 skipped, no new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:44:13 -04:00
Joseph Doherty 55e0178d5c merge(secrets): Task 3 client half — pull-only sweep + per-row parse isolation
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:32:04 -04:00
Joseph Doherty 1c31e6bcb8 fix(secrets): Grpc hub client — per-row parse isolation (review Critical) + real-client CallInvoker tests
A single malformed row or manifest entry poisoned the whole sweep, permanently.
WireToDomain.ToRows/ToManifest materialized eagerly, so one unparseable element
threw out of the fetch callback, past SecretReplicationReconciler's per-batch
handling, into SweepSafelyAsync's whole-sweep catch. The follower's pull set is
derived from the hub's manifest, so the identical bad element arrived in the
identical batch on every interval and nothing ever converged — logged as "sync
failed", indistinguishable from a hub outage.

Isolation now matches the Akka sibling's split exactly: WireToDomain stays a
strict per-item parser and loses its two batch entry points (their absence is
pinned by a reflection test), while GrpcSecretsHubClient converts each item
inside its own try — the same shape as SecretReplicationActor.Materialize.
A dropped item costs one warning naming the secret and a reason category, and
every well-formed sibling still converges. Only parse-shaped exceptions are
caught (ArgumentException, FormatException); a defect still propagates. A row
whose own name is unparseable is logged under a marker rather than echoing the
untrusted string, and no ciphertext, DEK or credential reaches a log line.

GrpcSecretsHubClient had zero coverage — the existing fake implements the
ISecretsHubReader seam directly, bypassing the header, the deadline and the
conversion, which are the only things that class does. It is now driven for
real through a fake CallInvoker handed to the generated client.

The stale doc comment claiming the reconciler isolated the failure is replaced
by one describing the mechanism that actually exists, and Endpoint gains a note
that a plain-http hub sends the bearer token cleartext (doc only, no validation
change).

Tests 52 -> 67 in the package suite; solution green, no new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:29:51 -04:00
Joseph Doherty 353af72265 fix(secrets): Grpc hub server review follow-ups — GetSecrets batch cap, DI single-registration guard + tests
Five review items on the Task 2 server half.

1. GetSecrets now enforces a server-side cap on request.Names, refusing an
   over-cap batch with ResourceExhausted BEFORE any store read runs. The
   well-behaved follower chunks at 500, but client discipline is not a server
   defence — the family precedent (HistorianGateway's max_values) is that the
   server declines regardless. The new SecretsGrpcHubServerOptions.
   MaxNamesPerRequest defaults to 1000, comfortably above that chunk so a
   compliant sweep never meets it, and Validate() refuses a non-positive value
   at startup rather than letting it read at runtime as a broken follower. The
   refusal states the cap and the received count — neither is secret, and an
   operator cannot resize a batch without them — and never a name. The cap sits
   behind the interceptor, so it is defence in depth against a misbehaving
   token holder, not an anonymous-DoS control.

2. AddZbSecretsGrpcHub now refuses a second registration. The two calls do not
   compose: options binding MERGES (a key absent from the second section leaves
   the first section's value standing) while the SectionPath stamp is
   last-one-wins, so a hub registered twice could serve a token bound from one
   section while every failure message named the other. Detected via a private
   marker registration. The extension also gains real DI coverage — the intended
   single-call path asserted for token/SectionPath coherence through a built
   provider, a custom section path stamped end to end, a missing token throwing
   from the registration call itself, and the duplicate call throwing.

3. Client-streaming and duplex-streaming denial tests, so all four gated handler
   shapes are pinned rather than only unary and server-streaming, plus a
   valid-token test that reaches all three streaming continuations.

4. A cancellation test: GetSecrets hands the ServerCallContext token through to
   the store, pinned against a fake that records it.

5. The proto's service comment stated the ISecretReplicationHub mapping in prose
   that no longer matched the RPC name; it now says GetManifestAsync ->
   GetManifest and GetManyAsync -> GetSecrets explicitly. Comment-only — no
   message or rpc definition is touched. The GetSecrets rpc comment also notes
   the new RESOURCE_EXHAUSTED cap, which a client author has to know about.

Grpc server suite 50 -> 73 tests; full solution suite green, 0 new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:27:16 -04:00
Joseph Doherty c5ea3a7b59 feat(secrets): Grpc hub client — pull-only sweep service + options + DI
Client half of the central-hosted secrets hub: a site node follows central and
never pushes.

- `ISecretsHubReader` + `GrpcSecretsHubClient` — the read half of the hub as a
  follower sees it (manifest + bulk fetch, nothing else). Bearer credential and a
  per-call deadline are attached per call; responses convert through
  `WireToDomain`. Faults are deliberately not caught here, so an unreachable hub
  can never be mistaken for an empty one.
- `WireToDomain` — strict, per-row proto -> domain parse mirroring the Akka
  replicator's `SecretRowDto`: a name the domain would reject, a content type
  naming no enum member, or a non-round-trip timestamp is refused at the boundary.
  Explicit proto3 presence keeps null distinguishable from empty.
- `GrpcSecretSyncService` — `SqlServerSecretSyncService` with the push half
  removed. One `ReconcileAsync` per tick (pull-only by the reconciler's own
  contract, and batched, so a cold-start pull set cannot exceed gRPC's 4 MB cap),
  never throws out of `ExecuteAsync`, warns once and retries on the next interval.
  The local store is left completely undecorated — the sweep is its only hub-ward
  writer.
- `SecretsGrpcHubClientOptions` — fail-closed `Validate()` naming the exact
  configuration key, including the section a caller actually bound. The bearer
  token is never echoed by any message.
- `AddZbSecretsGrpcHubClient` — binds, eagerly validates, and registers the
  channel under a service KEY so it cannot lose a `TryAdd` race to an
  application's own `GrpcChannel` and dial the wrong endpoint with the hub
  credential attached.

Tests 52/52 (red-first), including a source-scan guard pinning that no client file
names `ComputePushSet`, `ReadLocalAsync`, or `ApplyReplicatedAsync`, and a
reflection pin that the reader seam exposes exactly two read operations.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:12:03 -04:00
Joseph Doherty 7fc77f2790 feat(secrets): Grpc hub server — SecretsHubService + fail-closed bearer interceptor + DI
The server half of the central-hosted pull-only secrets hub. SecretsHubService
adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read
members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a
follower has no path to put a row into central whatever its credentials allow.

GetSecrets omits a name the hub does not hold and skips one that will not parse
as a SecretName; neither is an error, because both describe a follower acting on
a manifest that was true a moment ago. Failing the call would stall the sweep and
let one bad name deny the follower every other row in the batch. Names are
re-validated server-side (the wire type is a plain string) and de-duplicated on
the normalized value.

DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly:
round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the
last-writer-wins equality compare, the content type as its enum MEMBER NAME so an
unknown value is rejected rather than decoded to the zero member, and nullable
fields left absent rather than written as "" so null and empty stay distinct.

SecretsHubAuthInterceptor fails closed: with no token configured every call is
denied and one warning names the exact configuration key — never a token value,
presented or configured, at any level. Denial is uniform (one Status, one detail,
no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8
bytes. All four handler shapes are gated, not just unary, so a future RPC cannot
arrive unauthenticated by omission; more than one Authorization header is refused
as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's
activator silently stops invoking an interceptor it cannot construct
unambiguously, and the failure mode is an auth gate that quietly does not run — so
a test pins the count.

Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them
bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new
warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:09:23 -04:00
Joseph Doherty cc95590196 test(secrets): scaffold empty Grpc replicator test project (Tasks 2/3 drop files in without shared-file edits)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 05:57:42 -04:00
Joseph Doherty cf6d19d09d docs(secrets): add wire-evolution policy header to secrets_hub.proto (review follow-up)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 05:56:09 -04:00
Joseph Doherty 5fd7bf524a feat(secrets): scaffold ZB.MOM.WW.Secrets.Replicator.Grpc + secrets_hub.v1 pull-only proto
Adds the third replicator sibling next to Replicator.SqlServer and
Replicator.AkkaDotNet: the central-hosted gRPC secrets hub. This commit is
scaffold only -- project, wire contract, solution wiring -- no service or
client implementation.

The proto mirrors exactly the read half of ISecretReplicationHub
(GetManifestAsync, GetManyAsync). ApplyReplicatedAsync is deliberately NOT
mirrored: in this topology every update originates at central and a site node
is a strict follower, so the narrow contract IS the enforcement -- with no
write RPC on the wire, a mis-wired or compromised site node has no way to push
a row into the hub regardless of what its authorization allows.

Messages are hand-written mirrors of StoredSecret and SecretManifestEntry
rather than serializations of the domain types, following the Akka
replicator's SecretRowDto precedent: the trust boundary stays structural, and
a field added to StoredSecret later cannot start crossing the network merely
by existing. Timestamps are ISO-8601 "O" strings (not google.protobuf.
Timestamp, which cannot round-trip a DateTimeOffset's offset) so a relayed row
is byte-identical to the one central holds; nullable .NET fields use proto3
explicit presence so null and empty string stay distinguishable.

Grpc 2.76.0 / Google.Protobuf 3.34.1 match what ScadaBridge, OtOpcUa and the
sibling ZB.MOM.WW.LocalDb libraries already resolve, so a consumer taking both
gets one Grpc rather than a downgrade warning.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 05:48:27 -04:00
Joseph Doherty ad7df925ae docs(secrets): gRPC hub implementation plan (9 tasks, subagent-executable) + cached-on-sites posture made explicit
Plan + tasks ledger for scadaproj#3 execution: package (proto/server/client/
tests/publish 0.4.0), ScadaBridge wiring + live gate, OtOpcUa pair-local
re-gate, closeout. GAPS + runbook now state the gRPC mode keeps the
cached-on-site-clusters last-known-good posture (owner-confirmed).

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 05:36:32 -04:00
Joseph Doherty 591307c13e docs(secrets): production replication topologies decided — OtOpcUa Akka pair-local, ScadaBridge central gRPC hub (pull-only); scadaproj#3
Design doc + runbook Production-enablement section + GAPS/index updates.
The previously-untracked production-KEK decision is now recorded: one
fleet-wide KEK via appsettings/env (never committed); ScadaBridge rejects
the SqlServer hub on topology grounds (sites talk to central, never the
DB) in favor of a new ZB.MOM.WW.Secrets.Replicator.Grpc package —
pull-only, updates originate at central only, by design.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 05:28:46 -04:00
Joseph Doherty 537a4f524d docs: truth sweep — index + component GAPS reconciled with shipped state (Health 0.3.0 merged both apps, OtOpcUa LocalDb Phase 2 executed, Auth 0.1.5, Secrets 0.2.3/0.3.0)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 02:00:58 -04:00
Joseph Doherty 1a7bff6a19 docs(index): OtOpcUa #548 FIXED — FOCAS duplicate sibling names disambiguate (AA_1/AA_2, merge 8b5c478c)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 21:09:30 -04:00
Joseph Doherty c8c4c2bcde docs(index): OtOpcUa #539 FIXED (AbCip browse honours the authored port); #540 CLOSED, axis residual now #548
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:48:03 -04:00
Joseph Doherty 35f371ed74 docs(index): OtOpcUa ClusterClient→gRPC program CLOSED — Phase 4 terminal live gate PASS 8/8 (boundary proven, terminal suite 9,333/0)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:30:45 -04:00
Joseph Doherty 63cf4c6f0c docs(index): OtOpcUa Phase 3 ClusterClient deletion MERGED to master @ 2fa880b6; only the Phase 4 terminal gate remains
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:58:16 -04:00
Joseph Doherty 0e416cac0a docs(index): OtOpcUa ClusterClient→gRPC migration — Phases 0-2 merged (rig default Grpc), Phase 3 deletion complete on branch pending merge
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:24:06 -04:00
Joseph Doherty 7ed2c29842 docs(index): OtOpcUa #541 FIXED — drivers now report the tags they skipped (twelve, not eight) 2026-08-06 02:18:42 -04:00
Joseph Doherty 2b45a59c5d docs(index): OtOpcUa #540 deployed to wonder — the 20 FOCAS fixed-tree tags read 20/20 Good 2026-08-05 18:54:49 -04:00
Joseph Doherty d30f54639f docs(index): FOCAS fixed tree is publishable (OtOpcUa #540); driver Healthy no longer implies tags are polled (#541) 2026-08-05 17:49:11 -04:00
Joseph Doherty 0bbe42eaa1 docs(index): OtOpcUa Sql driver now ships SqlServer + Postgres + Oracle providers 2026-08-05 08:00:14 -04:00
Joseph Doherty 6d222ea491 docs: correct the self-form watchdog record — rejected, not shipped
The InitJoin self-form watchdog (a timer calling Cluster.Join(SelfAddress) after a
window) was implemented and REJECTED after live testing: it sits outside Akka's join
handshake, so on a routine standby restart — peer alive, join stalled behind removal of
the node's own stale incarnation — it fires mid-join, abandons the in-flight join and
forms a permanent second cluster at the same address (measured: still split after 90s;
OtOpcUa hit it live in its manual-failover drill).

The safety argument 'a booting node only self-forms when no seed answers InitJoin' is
true of Akka's NATIVE first-seed rule (FirstSeedNodeProcess, inside the handshake) and
FALSE of the watchdog, which can fire while a seed is answering. That reasoning error is
now called out explicitly so no future plan re-derives the pattern.

- docs/plans/2026-07-22-initjoin-selfform-fallback.md: dated correction banner (history
  preserved) + the two surviving false claims struck in place (behavior-spec row and the
  Mechanism section's 'races are benign'); points at ScadaBridge
  Component-ClusterInfrastructure.md as the surviving design.
- akka_failover.md 6.1: shipped design stated as self-first seed ordering plus the
  opt-in, default-OFF simultaneous-cold-start bootstrap guard; added a
  never-re-derive-the-watchdog note. 6.2: two items my 6.1 edit contradicted are marked
  closed (OtOpcUa auto-down live gate, mesh election scope — both done 2026-07-24).
- CLAUDE.md: ScadaBridge issue #14 is CLOSED (2026-07-24), not open — v3 dual-namespace
  live gates all passed, ScadaBridge consumes OtOpcUa v3 end-to-end.
2026-08-01 11:09:45 -04:00
Joseph Doherty 7a6f997180 docs(index): OtOpcUa #520 complete — ACL grants authored from a scope picker (stage 7)
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 10:54:19 -04:00
Joseph Doherty 555d1b4afc docs(index): OtOpcUa gates reads/browse/subscribe too — the previous entry said the opposite
#520 stage 6 (master 2026-08-01, live-gated). With Security:Acl:Enabled ON,
every data-plane operation needs a matching NodeAcl grant, not just writes /
alarm acks / HistoryRead. The stage-5 entry told ScadaBridge that a read-only
consumer was 'unaffected either way' — that now holds only while the flag is
off, which is the default.

Also records the two stage-6 behaviours a consumer would otherwise discover the
hard way: a deep Browse grant makes its ancestor folders browsable (so it
exposes their names), and revoking a grant stops a live subscription — one
BadUserAccessDenied then silence, resuming by itself if the grant returns, with
no reconnect needed.
2026-08-01 10:00:55 -04:00
Joseph Doherty 883601a72b docs(index): OtOpcUa node ACLs are now enforced behind Security:Acl:Enabled (#520 stage 5)
Default is off, so a read-only address-space consumer is unaffected. With it on,
writes / alarm acks / HistoryRead need a matching grant for the principal the
session authenticates as — anonymous unless ScadaBridge is issued a key.
Reads, browses and subscriptions stay ungated in both modes.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 05:01:07 -04:00
Joseph Doherty 6b12a7b75e docs(index): OtOpcUa AB CIP/Legacy now honour the authored EtherNet/IP port (#534) 2026-08-01 02:39:06 -04:00
Joseph Doherty 0121f55f1e docs(index): OtOpcUa's OPC UA data plane authenticates with API keys, not LDAP
Gitea #520 (OtOpcUa master, merge fbe7403c) replaced LdapOpcUaUserAuthenticator
with an API-key authenticator on the OPC UA data plane. The index still told a
consumer that enforcement was "LDAP roles plus a write gate", which is now the
wrong instruction for anyone wiring a client.

Records what a consumer actually needs: the UserName/`apikey`/`otua_...` token
shape, that LDAP survives for AdminUI login only, that anonymous still defaults
to admitted (so ScadaBridge is not broken today), and that revocation is
asymmetric because keys reach driver-only nodes inside the deployment artifact.
The "ACLs authored but not enforced" warning stands -- stages 3, 5 and 6 remove
it.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 00:29:17 -04:00
Joseph Doherty c07659c0e0 docs(index): OtOpcUa #527 fixed — comms-loss is detected and auto-recovers; withdraw the condition-Quality caveat 2026-07-31 10:59:50 -04:00
Joseph Doherty ded7cf0d6d docs(index): OtOpcUa — Sql browser layering (#526), IDriver dead-surface removal (#525), scripted-alarm comms-loss quality (#481) + the #527 caveat 2026-07-31 10:04:50 -04:00
Joseph Doherty f7f4554a36 docs(index): OtOpcUa MQTT driver — write-through (#508), Sparkplug primary-host STATE (#511), structured Template/DataSet flattening (#515)
All three live-gated and merged to OtOpcUa master. The index still said write-through was
deferred and every MQTT node read-only, which #508 changed on 2026-07-31; #511 and #515
landed in merge 857a10b4. Two of the three are consumer-visible to ScadaBridge: MQTT nodes
now materialize Operate rather than ViewOnly where a write destination is declared, and a
Sparkplug UDT surfaces as ordinary scalar nodes instead of being skipped.
2026-07-31 08:15:31 -04:00
Joseph Doherty 850e23646a docs(index): OtOpcUa — #521 host connectivity now consumed via telemetry; #522 tier machinery deleted
Both were recorded as open keep-or-delete decisions; both closed on OtOpcUa master abda6f9e.
Neither changes the OPC UA wire contract, so neither is consumer-facing to ScadaBridge — but
the index asserted them as current state, so it would have drifted.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-30 04:59:06 -04:00
Joseph Doherty ff073450dc docs(index): OtOpcUa — tag-set drift detection REMOVED (#523, merge 2a0461a1)
The entry claimed the periodic tag-set drift check had shipped. It was taken back
out two days later and is not in master. Corrected in place rather than deleted,
so the record shows both what landed and what became of it.

Carries forward the consequence a reader needs: AbCip and FOCAS browse a live
backend but implement no IRediscoverable, so they now have no tag-change signal
at all. Galaxy/TwinCAT/MTConnect/Mqtt raise natively and are unaffected. Still
not consumer-facing to ScadaBridge -- no OPC UA wire-contract change either way,
and the IRediscoverable operator prompt is untouched.

Also records the rest of that merge: 13 stale skipped tests retired, a reflection
guard over the deploy-time TagConfig gate, three silently-failing OpcUaClient
integration tests fixed, and the two missing driver guides written.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-30 03:42:45 -04:00
Joseph Doherty e96b5d49f9 docs(index): OtOpcUa — deferment remediation merged to master (914d70bf)
The index recorded this as unmerged on a feature branch; it landed on master
2026-07-28. Also records the two follow-ups the first pass had deliberately
left unbuilt and which have since shipped: Sql/FOCAS in-place config re-parse
(made atomic, so a config change cannot half-apply a new tag set against an
old connection or backend), and tag-set drift detection for the four
genuinely-browsable drivers.

Neither is consumer-facing to ScadaBridge — no OPC UA wire-contract change, and
the drift signal is advisory rather than an address-space rebuild. The
previously-noted operationally-visible item stands: a DriverConfig edit now
costs a driver reconnect.
2026-07-28 13:24:36 -04:00
Joseph Doherty b0b57f8d54 docs(index): OtOpcUa — rediscovery is now a prompt; deferment remediation underway
Corrects a fact this index recorded two days ago as consumer-relevant to
ScadaBridge. "IRediscoverable is inert fleet-wide" no longer holds: it is
consumed as an operator re-browse prompt on /hosts, carried on
DriverHealthChanged. The consumer-facing conclusion is unchanged and worth
keeping explicit — the served address space still only changes on a config
redeploy, because the prompt is advisory by design.

Gitea #507 (discovered-node injection) is closed as superseded and its code
deleted; it read two structures that are structurally empty in v3, so it could
never have injected anything. #521 tracks the still-unconsumed
IHostConnectivityProbe.

Adds the remediation status for the deferment register (branch
feat/deferment-remediation, tip 88ce8df0, unmerged). One item there is
operationally visible beyond OtOpcUa: #516 makes a changed DriverConfig
stop+respawn the driver rather than an in-place delta, so a config edit now
costs a driver reconnect — previously five drivers ignored the edit entirely
while the deployment still sealed green.
2026-07-27 20:04:32 -04:00
Joseph Doherty eedbf13712 docs(index): OtOpcUa — mesh program COMPLETE (Phase 7 + bootstrap guard), deferment register
Corrects four stale claims about OtOpcUa and adds two consumer-relevant facts,
all source-verified against the OtOpcUa repo (not its docs):

  * "Phases 0a..6 done, 7 not started" -> PROGRAM COMPLETE. Phase 7 merged
    c50ebcf7 (6/6 failover drills) + bootstrap guard d1dac87f, both on master
    and pushed 2026-07-24.
  * The auto-down 1-vs-1 crash-the-oldest live gate is no longer "still open" /
    "deferred to Phase 7" — Phase 7 drill D4 closed it; every survivor stayed
    Up without self-downing.
  * Phase 6's live gate was NOT "deferred by owner choice" — it ran and caught
    three fix-forward defects, including a boot-crash StackOverflow on every
    node (IClusterRoleInfo resolved inside the AddAkka configurator while the
    ActorSystem was still building).
  * Records the MAIN-only manual-failover constraint and the default-OFF
    bootstrap guard's founder-election shape.

Adds OtOpcUa/deferment.md (e08855fb) and its two ScadaBridge-relevant entries:
  * Node ACLs are authored, shipped in every artifact, and NOT ENFORCED — the
    evaluator subsystem has zero production consumers and OtOpcUaNodeManager
    never references it. Reads are ungated; authored deny rules have no effect.
    Do not assume OtOpcUa filters an address space per-user.
  * IRediscoverable is inert fleet-wide (#518/#507) — nine drivers raise it,
    nothing consumes it, so a Galaxy redeploy / PLC re-download / Agent restart
    does not change the served address space without a config redeploy.
2026-07-27 17:28:28 -04:00
Joseph Doherty 035c5feeb8 Merge branch 'feat/overview-dashboard'
# Conflicts:
#	CLAUDE.md
2026-07-27 15:26:37 -04:00
Joseph Doherty b02d7a92bb docs(index): record OtOpcUa's MTConnect Agent driver (merged to master, PR #506)
Keeps the umbrella index in step with the OtOpcUa driver set, per the
propagation rule in that repo's CLAUDE.md. Read-only Agent driver:
hand-rolled HTTP/XML client (probe/current + multipart /sample pump),
TrakHound dependency evaluated and dropped, browse free via the Wave-0
universal discovery browser, write-back deferred. Live-gated against a
real mtconnect/agent 2.7.0.12 fixture.
2026-07-27 14:03:15 -04:00
Joseph Doherty 75f267c4c9 docs(index): index edits go on main and get pushed
Sister projects are told to update their entry here whenever an indexed fact
changes, but that edit lands in this repo's own checkout — so a naive commit
writes it onto whatever branch scadaproj happens to be on. Observed 2026-07-27:
the OtOpcUa Sql driver's entry sat unpushed on feat/overview-dashboard for three
days, and the Mqtt entry was about to join it.

Records the procedure (stash, main, push, restore) and says to cherry-pick
stranded index commits rather than merge the branch they are stuck on.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-27 13:36:18 -04:00
Joseph Doherty 7dcd93c326 docs(index): OtOpcUa's MQTT / Sparkplug B driver is merged to master
Merge c3a2b0f7, pushed to origin 2026-07-27. Supersedes the
"on branch feat/mqtt-sparkplug-driver, not yet merged" note in bf75e26.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-27 13:34:10 -04:00
Joseph Doherty 591eaac881 docs(index): record OtOpcUa's MQTT / Sparkplug B driver
The OtOpcUa entry's driver set changed, so the index has to change with it
(OtOpcUa CLAUDE.md's cross-repo rule).

A read-only `Mqtt` broker-subscribe driver landed with two ingest shapes over
one MQTTnet-5 client: Plain binds a tag to a concrete topic, SparkplugB decodes
vendored Eclipse-Tahu protobuf under a single spBv1.0/{groupId}/# subscription
and binds by the group/edgeNode/device?/metric tuple (birth/alias/seq-gap/
rebirth state machine, death→STALE, a birth-driven browse picker with a scoped
Request-rebirth NCMD). Write-through is deferred — every MQTT node is read-only.

Live-gated on docker-dev against a Mosquitto TLS+auth broker plus a
project-owned C# Sparkplug edge-node simulator on 10.100.0.35:8883.

Recorded as **on branch feat/mqtt-sparkplug-driver, not yet merged to master**,
so the index does not overstate what is on OtOpcUa's default branch — unlike the
Sql DB-poll driver next to it, which is merged.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-27 13:34:10 -04:00