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
This commit is contained in:
Joseph Doherty
2026-08-07 09:00:11 -04:00
parent c86cead5c2
commit 5198d3dc7f
4 changed files with 91 additions and 16 deletions
+54 -2
View File
@@ -179,12 +179,64 @@ project 38/38 green.
sites-talk-to-central-not-the-DB rule. **The gRPC mode keeps the cached-on-site-clusters
posture:** each site node holds a full local store and serves last-known-good secrets when
central is unreachable, converging on the first successful sweep — same offline guarantee as
the SQL hub mode, without the site-side DB connection. Enablement waits on the hub package (ScadaBridge) and the
pair-local re-gate on the post-Phase-6 rig (OtOpcUa).
the SQL hub mode, without the site-side DB connection. ~~Enablement waits on the hub package (ScadaBridge) and the
pair-local re-gate on the post-Phase-6 rig (OtOpcUa).~~ **Both prerequisites are now DONE
(2026-08-07) — see "gRPC hub delivered" below; scadaproj#3 is CLOSED. Enablement is now purely
an operator action: the flag stays default-off in both products.**
- ✅ ScadaBridge's hub topology **is** live-validated and technically safe to enable — but the
2026-08-07 decision rejects it for production on topology grounds (site nodes must not hold a
central SQL connection string); superseded by the gRPC hub above.
### gRPC hub delivered — scadaproj#3 CLOSED 2026-08-07
- ✅ **`ZB.MOM.WW.Secrets.Replicator.Grpc` BUILT and PUBLISHED.** Family `0.4.0`
(scadaproj `main @ 321ab22`), then `0.4.1` (`main @ c86cead`) adding a rate-limited denial
Warning found by the live gate — 6 packages each on the `dohertj2-gitea` feed, restore-verified.
`ZB.MOM.WW.Secrets/CHANGELOG.md` created in the same pass. Grpc package suite 166 tests;
solution 444 pass / 0 fail / 15 skipped.
- **Pull-only is enforced at the contract, not by convention:** `secrets_hub.v1` declares
exactly two RPCs — `GetManifest` and `GetSecrets` — and no write RPC exists to call. Pinned by
a descriptor-reflection test in the package and a route pin on the ScadaBridge side.
- Fail-closed bearer auth: constant-time compare, uniform denial (no oracle), a single public
constructor so the auth-less shape is unconstructable, and a `GetSecrets` batch cap that
answers `ResourceExhausted`.
- Site side: `GrpcSecretSyncService`, a never-throw pull-only sweep with per-row parse isolation
(one malformed row cannot poison the sweep) over a keyed `GrpcChannel`; offline =
last-known-good.
- ✅ **ScadaBridge wired both ends and live-gated — PASS 4/4.** `ScadaBridge main @ f6c3f7c5`
(fast-forward merged + pushed; branch `feat/secrets-grpc-hub` retained). Gate doc
`ScadaBridge/docs/plans/2026-08-07-secrets-grpc-hub-live-gate.md`.
- Wiring: the node role is a **method parameter** (`SecretsNodeRole.Central|Site`), not a config
key — configuration cannot flip a site node into hosting the hub. Mode key
`Secrets:Replication:Mode` ∈ {`SqlServer` (default, legacy-equivalent), `Grpc`}. The hub is
mapped on central's existing h2c control-plane listener (`CentralGrpcPort`, default 8083) —
no new port. Startup fails closed on a missing `Secrets:GrpcHub:BearerToken` or `Endpoint`.
31 DI/endpoint pin tests.
- Gate results: convergence 5 s, byte-identical and decrypt-verified on **both** site-a nodes;
cold boot with central down serves last-known-good and resumes (14 s / 43 s); tombstone ≤9 s
with no resurrection; auth negatives uniform, the 0.4.1 denial Warning fired immediately, the
rate limit proven to **defer, not drop**, and zero credential material in any log — including
the rejected tokens themselves.
- Flag remains **default-off everywhere**; the rig enables the central pair and site-a only.
- ✅ **OtOpcUa pair-local re-gate — PASS 4/4.** `lmxopcua master @ 1bd79c8e`, gate doc
`OtOpcUa/docs/plans/2026-08-07-secrets-pair-local-live-gate.md`. Pair convergence ≤7 s;
intra-pair delete <1 s; clean pair restart; and **no cross-pair propagation proven with seeded
isolation controls on the other pairs**, not by mere absence of a row. This supersedes the
2026-07-18 six-node gate as evidence — that gate predates the mesh Phase 6 partition, and
Phase 6's own secrets leg was inference-only (no secret was ever seeded during it). Rig fix that
landed with the gate: `Secrets__SqlitePath` moved onto each node's per-node `/app/data` volume,
where it had been sharing a path.
- ⚠️ **Residual, filed as [scadaproj#4](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/4):
ScadaBridge's central pair does not converge with itself, and the site client dials a single
`Endpoint`.** Each central node has an independent local store; live-observed, `central-b`
answered an *authenticated* `GetManifest` with an **empty manifest** for the whole gate run
while `central-a` held every secret. These are one design question, not two: adding endpoint
failover alone would let a failover *succeed* against an empty hub — not data loss (pull-only
never deletes) but a **silent convergence stop**, and secrets authored on the central node the
sites do not dial reach nobody. Folded into the same issue: delete-while-follower-offline is
untested, and alerting on the denial Warning must sum per-window deltas because the warning is
deliberately rate-limited. **ScadaBridge production enablement should wait on #4.**
Harness: `scratchpad/livegate/` (two scenarios, `akka` / `sql`; not committed).
### Upstream defect found during adoption — `0.2.0` Akka replication was inert