Files
ScadaBridge/docs/plans/2026-08-07-secrets-grpc-hub-live-gate.md
T
Joseph Doherty f6c3f7c593 test(secrets): live gate 4/4 — check 4 re-run and PASSES on 0.4.1
Second pass on the 0.4.1 image, from a clean start with zero denial warnings on
central-a. Both auth negatives are still refused with a byte-identical
Unauthenticated status and detail - 0.4.1 changed what the server writes down,
not what a caller sees - and the no-bearer call now produces a WRN in the same
second it is refused, with the cause attributed. No interval has to elapse for
the first denial to be visible, which is the whole point: a follower with a
mis-rotated token is refused on every sweep, and central now says so immediately.

The rate limit was proven to DEFER rather than drop, not assumed to. The
wrong-bearer call one second later fell inside the 60 s window and produced no
line of its own; the window was waited out and one further wrong-bearer call
issued, whose summary reported TWO wrong-credential denials - the deferred one
plus the new one. Summing the two lines gives exactly the three negatives issued,
correctly attributed by cause. N is a per-window delta, so a reader must sum the
lines rather than quote the last one; that is recorded as a follow-up because it
is the kind of thing an alert gets wrong.

Log hygiene re-run fleet-wide and widened: all eight nodes' docker logs and every
on-disk Serilog file were grepped for the dev token, the dev KEK, all three secret
plaintexts AND both wrong tokens the negatives presented. Zero hits everywhere.
The presented-credential check is deliberate - echoing a rejected credential back
into a log is its own leak and a free oracle, and the new warning counts denials
by cause without carrying any credential material.

Checks 2 and 3 were not repeated: 0.4.1 touches the hub's denial logging and
nothing else - no wire change, no store change, no sweep change. Convergence was
re-smoked instead so the new image is not merely assumed to replicate: a fresh
secret reached both followers in 17 s, byte-identical and decrypt-verified on
both, and the first pass's live secret and tombstone survived the image swap
unchanged on all three nodes.

The first-pass FAIL evidence is kept intact rather than overwritten. The fix only
means anything against the failure it answers, and a gate doc that shows only the
green run cannot be audited.

Residuals stand as recorded: the hub client dials a single endpoint with no
failover, and the central pair does not converge with itself - central-b answered
an authenticated GetManifest with an empty manifest for the whole run. Those are
one question, not two.

4/4. Merging.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 08:51:39 -04:00

456 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Secrets gRPC hub — live gate on the docker cluster
**Result: PASS — 4/4 checks.** Run 2026-08-07 on the local 8-node docker cluster.
Run in two passes. The **first pass** (image `2958aa2c2bbb`, secrets libs **0.4.0**) passed
checks 13 and failed one clause of check 4: denials were recorded at Information, not
Warning. That was a property of the library, not of this branch, so it was fixed at the
library and shipped as **0.4.1** (`scadaproj` main `c86cead`). The **second pass** (image
`f866208c89e8`, secrets libs **0.4.1**) re-ran check 4 in full and re-smoked convergence to
prove the new image still converges. Both passes are recorded below, first-pass evidence
kept intact — the fix is only meaningful against the failure it answers.
## Purpose
Terminal gate (Task 7) for `feat/secrets-grpc-hub`, which wires
`ZB.MOM.WW.Secrets.Replicator.Grpc` 0.4.0 into ScadaBridge: the central role hosts a
**pull-only** `secrets_hub.v1` gRPC hub on its existing h2c control-plane listener, and a
site node runs a sweep that converges its **own local SQLite store** from that hub.
This is the ScadaBridge half of the production-topology decision in
[scadaproj#3](https://gitea.dohertylan.com/dohertj2/scadaproj/issues/3) (design:
`scadaproj/docs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md`). The
SqlServer replicator is not the production path here: it requires every site node to hold a
connection string to central's database, which breaks ScadaBridge's standing rule that
sites talk to central, not to central's DB.
**Scope: rig config only.** No product code was changed by this gate — see
[Defects](#defects-found).
## Rig state
| Fact | First pass | Second pass |
|---|---|---|
| Repo / branch | `ScadaBridge` @ `feat/secrets-grpc-hub` | same |
| Git SHA gated | `fc784b413713b1643a21d573e55c00bcc05bd8ee` (`fc784b41`) | `6d38e89be0b39a2224559dd50922751ccd8df2ed` (`6d38e89b`) |
| Image | `scadabridge:latest` = `2958aa2c2bbb`, built 11:39 UTC | `scadabridge:latest` = `f866208c89e8`, built 12:41 UTC |
| `ZB.MOM.WW.ScadaBridge.Host.dll` | `09cf76dc550d32fe…` — byte-identical on all 8 nodes | `bc8f17e7b5d6982c…` — byte-identical on all 8 nodes |
| Secrets libs | `ZB.MOM.WW.Secrets*` **0.4.0** | `ZB.MOM.WW.Secrets*` **0.4.1** |
| Working tree at build | only `docker/docker-compose.yml` modified (rig config, not in the image) + untracked `.claude/` | clean but for untracked `.claude/` |
Common to both passes:
| Fact | Value |
|---|---|
| Hub enabled on | `central-a`, `central-b` (host) and `site-a-a`, `site-a-b` (followers) |
| Hub NOT enabled on | `site-b-a`, `site-b-b`, `site-c-a`, `site-c-b` — no `Secrets__*` env, no store file, no hub/sweep log line (verified) |
| Sweep interval | `00:00:30` — the **product default**, deliberately not shortened, so the timings below are real |
| KEK | one shared committed **dev-only** key, identical on all four (`kek_id` sha256 `7451bcbc1f1f` on every row observed) |
`site-b` and `site-c` were left off on purpose: the default-OFF posture is proven side by
side on one rig, the same way `site-a` is the rig's only LocalDb-replicated pair.
### Rig-config change made by this gate
`docker/docker-compose.yml` gained two YAML anchors and applied them to four services:
- `x-secrets-hub-env` (central pair + site-a pair) — dev KEK, `Secrets__SqlitePath`,
`Secrets__Replication__Enabled=true`, `Secrets__Replication__Mode=Grpc`,
`Secrets__GrpcHub__BearerToken`.
- `x-secrets-hub-site-env` (site-a pair only) —
`Secrets__GrpcHub__Endpoint=http://scadabridge-central-a:8083`.
All values are DEV-ONLY and committed under the same exception the mesh PSKs and
`InboundApi:ApiKeyPepper` already use. `Secrets__SqlitePath` points at `/app/data` because
the appsettings default is a relative path resolving to `/app` — inside the image's
writable layer, destroyed by every container recreate. The **central pair gained a
`./central-node-*/data:/app/data` volume** for this; the site pairs already had one.
## Method
Secrets were seeded, read and deleted with the `ZB.MOM.WW.Secrets.Cli` (`secret`) published
from `scadaproj/ZB.MOM.WW.Secrets` (0.4.0) and run **in a throwaway container** mounting one
node's `data` volume, with that node's KEK. This is the method the sibling OtOpcUa gate used
the same day (`OtOpcUa/docs/plans/2026-08-07-secrets-pair-local-live-gate.md`).
The CentralUI `/admin/secrets` page was **not** used, and for this topology that costs
nothing: the hub is a pure reader of central's store on every call, with no write-triggered
path and no manifest cache. A row written by any process into central's store is on exactly
the same path a row written through the page would be. (Contrast the Akka replicator, where
the two differ — hence that gate's residual 1, which does not apply here.)
Ciphertext identity is evidenced as a **SHA-256 over the encrypted columns only**
(`ciphertext‖nonce‖tag‖wrapped_dek‖wrap_nonce‖wrap_tag`), plus a hash of `kek_id`. No
plaintext, no token and no key material appears in this document. The secret values used
were throwaway smoke strings.
gRPC calls for check 4 were made with `fullstorydev/grpcurl` on the `scadabridge-net`
network, against the committed `secrets_hub.proto`.
## Results
| # | Check | Pass | Result | Timing |
|---|---|---|---|---|
| 1 | Convergence — central write reaches both site-a nodes, byte-identical, decrypt-verified | 1st (0.4.0) | **PASS** | **5 s** (budget 30 s + margin) |
| 2 | Cold-boot offline — site pair boots and serves last-known-good with central down; convergence resumes | 1st (0.4.0) | **PASS** | resumed in **14 s** / **43 s** |
| 3 | Tombstone — delete propagates, survives a pair restart | 1st (0.4.0) | **PASS** | **≤ 9 s** |
| 4 | Auth negatives + log hygiene | 1st (0.4.0) | ~~FAIL~~ (1 clause of 3) | — |
| 4 | Auth negatives + log hygiene — **re-run in full** | 2nd (0.4.1) | **PASS** | denial warned immediately |
| — | Convergence re-smoke on the 0.4.1 image | 2nd (0.4.1) | **PASS** | **17 s** |
Baseline before seeding: all four stores empty.
### Check 1 — convergence · PASS
Seeded `hub-gate-smoke-1` on **`central-a` only** at 11:46:57Z.
```
central-node-a hub-gate-smoke-1 rev=0 del=0 cipher_sha256=00a14a2eb8de987fb75a4515 kek=7451bcbc1f1f updated=2026-08-07T11:46:57.1403922+00:00
site-a-node-a hub-gate-smoke-1 rev=0 del=0 cipher_sha256=00a14a2eb8de987fb75a4515 kek=7451bcbc1f1f updated=2026-08-07T11:46:57.1403922+00:00
site-a-node-b hub-gate-smoke-1 rev=0 del=0 cipher_sha256=00a14a2eb8de987fb75a4515 kek=7451bcbc1f1f updated=2026-08-07T11:46:57.1403922+00:00
```
Ciphertext hash, `kek_id`, `revision` and `updated_utc` are **identical on all three** — the
row is relayed verbatim, not re-encrypted, so the last-writer-wins ordering key survives the
hop. First poll after the seed (11:47:05Z) already showed both followers converged.
Both halves logged it, and central served the fetch:
```
site-a-a [11:47:02 INF] Secret hub sync converged: pulled 1 row(s) from the central hub.
site-a-b [11:47:02 INF] Secret hub sync converged: pulled 1 row(s) from the central hub.
central-a [11:47:02 INF] Request finished HTTP/2 POST .../SecretsHub/GetSecrets - 200 application/grpc 0.9899ms
```
**Convergence = 11:46:57Z → 11:47:02Z = 5 s**, one sweep tick.
**Decrypt-verified on both followers**: `secret get hub-gate-smoke-1` returned the exact
plaintext seeded on central-a, on `site-a-a` and `site-a-b`.
### Check 2 — cold-boot offline · PASS
Central pair stopped 11:48:27Z → 11:48:37Z.
**Sweep degrades, does not crash.** One warning per interval on each follower, carrying the
transport fault and nothing else:
```
site-a-a [11:49:02 WRN] Secret hub sync failed; the node continues serving its local store and will retry on the next interval.
Grpc.Core.RpcException: Status(StatusCode="Unavailable", Detail="Error connecting to subchannel." …)
```
**Site pair restarted together** (the supported pattern) 11:49:31Z → 11:50:12Z, with central
still down. `site-a-a` booted at 11:49:42Z; its `SyncOnStartup` sweep warned at 11:49:42Z and
retried at 11:50:12 / 11:50:42 / 11:51:12 / 11:51:42 / 11:52:12 / 11:52:42. Both containers
`running`, `RestartCount=0`, **zero** `FATAL` / `Unhandled exception` /
`Application startup exception` lines.
**Last-known-good served with central down** — at 11:51:08Z, `secret get hub-gate-smoke-1`
returned the correct plaintext on **both** `site-a-a` and `site-a-b`.
Central pair started 11:51:37Z. `hub-gate-smoke-2` seeded on central-a at 11:52:29Z — **after
the site nodes were already up**, so this proves convergence *resumed* rather than
re-bootstrapped:
```
site-a-b [11:52:43 INF] Secret hub sync converged: pulled 1 row(s) from the central hub. → 14 s
site-a-a [11:53:12 INF] Secret hub sync converged: pulled 1 row(s) from the central hub. → 43 s
```
Both rows byte-identical to central's (`cipher_sha256=4f022e222cfc2f8f77dcfb0b`,
`updated=2026-08-07T11:52:29.6983529+00:00`).
**Honest note on the 43 s.** `site-a-a`'s first post-outage sweep (11:52:42Z) *still* failed,
with `SocketException: Name or service not known` — a stale DNS/subchannel view of a
container that had been `docker stop`ped and `docker start`ed. It recovered unaided on the
very next tick. So after a central outage, worst-case convergence on this rig is **two**
sweep intervals, not one. Bounded, self-healing, no intervention — but recorded so the 5 s
figure from check 1 is not read as the post-outage number.
### Check 3 — tombstone · PASS
`hub-gate-smoke-1` deleted on central-a at 11:54:05Z. `hub-gate-smoke-2` left live as a
control.
```
central-node-a hub-gate-smoke-1 rev=1 del=1 updated=2026-08-07T11:54:05.9812744+00:00 deleted=2026-08-07T11:54:05.9812744+00:00
site-a-node-a hub-gate-smoke-1 rev=1 del=1 updated=2026-08-07T11:54:05.9812744+00:00 deleted=2026-08-07T11:54:05.9812744+00:00
site-a-node-b hub-gate-smoke-1 rev=1 del=1 updated=2026-08-07T11:54:05.9812744+00:00 deleted=2026-08-07T11:54:05.9812744+00:00
```
Both followers held the tombstone at the 11:54:14Z poll — **≤ 9 s**. Delete is a soft
tombstone (`is_deleted=1`, `revision` bumped, `updated_utc` restamped), which is what lets it
win under last-writer-wins. `secret get` returned `{"error":"not-found"}` and `secret list`
returned only `hub-gate-smoke-2` on both followers.
**No resurrection across a pair restart.** Site pair restarted together 11:55:09Z →
11:55:50Z, **with central up and sweeping the whole time** — so the sweep had every
opportunity to re-pull the row. Re-checked at 11:57:05Z (75 s / 2+ sweeps later):
- `hub-gate-smoke-1` still `rev=1 del=1` on both, `updated_utc` unchanged;
`secret get``{"error":"not-found"}` on both.
- `hub-gate-smoke-2` survived on both and **decrypted to the correct plaintext** on both.
### Check 4, first pass (0.4.0) — auth negatives + log hygiene · FAIL (one clause)
Kept for the record; superseded by the second pass below.
Three calls to `SecretsHub/GetManifest` on `scadabridge-central-a:8083` at 11:57:3132Z:
| Call | Result |
|---|---|
| **no** `authorization` header | `Code: Unauthenticated` / `Message: Unauthenticated.` |
| **wrong** bearer (`Bearer not-the-hub-token`) | `Code: Unauthenticated` / `Message: Unauthenticated.` |
| correct bearer (positive control) | `200`, manifest returned (2 entries) |
**PASS — identical denial.** Both negatives are byte-identical in code and detail: a caller
learns it was refused and nothing else. The positive control proves the endpoint is live and
the refusals are the auth gate, not a dead route.
**PASS — log hygiene, zero hits.** Every one of the **8** nodes' `docker logs` *and* every
on-disk Serilog file under `docker/*/logs/` was grepped for the dev bearer token value, the
dev KEK value, and both secret plaintexts:
```
central-a token=0 kek=0 plain1=0 plain2=0 authz-header-mentions=0
central-b token=0 kek=0 plain1=0 plain2=0 authz-header-mentions=0
site-a-a token=0 kek=0 plain1=0 plain2=0 authz-header-mentions=0
site-a-b token=0 kek=0 plain1=0 plain2=0 authz-header-mentions=0
site-b-a / site-b-b / site-c-a / site-c-b all zero
on-disk log files: zero matching files for all four search strings
```
The token appears only where it legitimately may: the committed rig config
(`docker/docker-compose.yml`) and the resulting container environment.
**FAIL — no server-side *warning*.** The criterion asks for a server-side warning on denial.
What the hub actually emits is one **Information**-level line per denial, from
`Grpc.AspNetCore.Server`, not from the hub's own gate:
```
[11:57:31 INF] Error status code 'Unauthenticated' with detail 'Unauthenticated.' raised.
[11:57:32 INF] Error status code 'Unauthenticated' with detail 'Unauthenticated.' raised.
```
Nothing at `WRN` or above was emitted by either denial — verified by dumping **every** log
line in the 11:57:3132Z window, not by a filtered grep.
This is **not a wiring mistake and not fixable in this repo**: `SecretsHubAuthInterceptor`
(in `ZB.MOM.WW.Secrets.Replicator.Grpc` 0.4.0) deliberately logs *nothing* on a denial. Its
only warning is `WarnUnconfiguredOnce`, emitted once when the hub has **no token configured
at all** — which is not the case here. So the shipped design and this gate's criterion
disagree, and the disagreement lives in the library.
The consequence is real but narrow: a follower whose token was mis-rotated is refused on
every sweep and **stops converging**, while central records that only at Information. An
operator watching for warnings sees the follower's own `Secret hub sync failed` WRN (which
does fire), but central shows nothing above INF.
**Not patched in ScadaBridge.** A host-side interceptor could log the denial, but that would
(a) contradict a decision the library documents and reasons about explicitly, in the
consumer rather than at the layer that owns it, and (b) put an unbounded log write on an
unauthenticated endpoint. Both are worse than the gap. It was fixed at the layer that owns
it instead — see the second pass.
### Check 4, second pass (0.4.1) — auth negatives + log hygiene · PASS
Re-run in full on the 0.4.1 image (`f866208c89e8`, branch `6d38e89b`), from a clean start:
zero `The gRPC secrets hub denied` lines on `central-a` before the first negative.
**Denial semantics — unchanged, as intended.** Three calls to `SecretsHub/GetManifest` on
`scadabridge-central-a:8083`:
| Call | Time | Result |
|---|---|---|
| **no** `authorization` header | 12:46:09Z | `Code: Unauthenticated` / `Message: Unauthenticated.` |
| **wrong** bearer | 12:46:10Z | `Code: Unauthenticated` / `Message: Unauthenticated.` |
| correct bearer (positive control) | 12:46:10Z | `200`, manifest returned |
| **wrong** bearer, next window | 12:47:38Z | `Code: Unauthenticated` / `Message: Unauthenticated.` |
Byte-identical code and detail on every refusal, and the positive control proves the route
is live. The wire contract did not move: 0.4.1 changed only what the server writes down.
**The warning now fires, and fires on the first denial.** No interval had to elapse — the
no-bearer call at 12:46:09Z produced a `WRN` in the same second it was refused:
```
central-a [12:46:10 WRN] The gRPC secrets hub denied 1 call(s) since the previous such warning
(missing or malformed credential: 1; wrong credential: 0; hub token unconfigured: 0).
Denials are summarised at most once every 60s.
```
**Rate limiting defers, it does not drop.** The wrong-bearer call one second later fell
inside that 60 s window, so it produced no line of its own. Rather than assume it was
counted, the window was waited out and one further wrong-bearer call issued at 12:47:38Z:
```
central-a [12:47:38 WRN] The gRPC secrets hub denied 2 call(s) since the previous such warning
(missing or malformed credential: 0; wrong credential: 2; hub token unconfigured: 0).
```
The second summary reports **two** wrong-credential denials — the deferred 12:46:10Z one plus
the 12:47:38Z one. Summing across lines gives 1 missing + 2 wrong = **3**, exactly the three
negatives issued, with the causes correctly attributed. `N` is a per-window delta, so a
reader must sum the lines rather than quote the last one. Nothing was lost to the rate limit,
which is the only property that matters for the failure this fix exists to make visible: a
follower with a mis-rotated token is refused on every sweep, and central now says so at
`WRN` within a second of the first attempt.
**Log hygiene — zero hits, fleet-wide.** All **8** nodes' `docker logs` *and* every on-disk
Serilog file under `docker/*/logs/` were grepped for the dev bearer token, the dev KEK, all
three secret plaintexts, **and both wrong tokens presented by the negatives** — the last of
those deliberately, because echoing a rejected credential back into a log is its own leak and
a free oracle:
```
central-a token=0 kek=0 p1=0 p2=0 p3=0 wrong1=0 wrong2=0
central-b token=0 kek=0 p1=0 p2=0 p3=0 wrong1=0 wrong2=0
site-a-a token=0 kek=0 p1=0 p2=0 p3=0 wrong1=0 wrong2=0
site-a-b token=0 kek=0 p1=0 p2=0 p3=0 wrong1=0 wrong2=0
site-b-a / site-b-b / site-c-a / site-c-b all zero
on-disk log files: 0 files matched, for each of the 7 search strings
```
The new warning counts denials and names their cause; it carries no credential material,
presented or configured. The token still appears only where it legitimately may: the
committed rig config and the resulting container environment.
### Convergence re-smoke on the 0.4.1 image · PASS
Checks 2 and 3 were not repeated — 0.4.1 touches only the hub's denial logging. Convergence
was re-smoked to prove the new image still replicates end to end:
`hub-gate-smoke-3` seeded on `central-a` at 12:47:54Z:
```
central-node-a hub-gate-smoke-3 rev=0 del=0 cipher_sha256=fc3cee5431cab8f3a7df303c kek=7451bcbc1f1f updated=2026-08-07T12:47:54.3769041+00:00
site-a-node-a hub-gate-smoke-3 rev=0 del=0 cipher_sha256=fc3cee5431cab8f3a7df303c kek=7451bcbc1f1f updated=2026-08-07T12:47:54.3769041+00:00
site-a-node-b hub-gate-smoke-3 rev=0 del=0 cipher_sha256=fc3cee5431cab8f3a7df303c kek=7451bcbc1f1f updated=2026-08-07T12:47:54.3769041+00:00
site-a-a [12:48:11 INF] Secret hub sync converged: pulled 1 row(s) from the central hub.
site-a-b [12:48:11 INF] Secret hub sync converged: pulled 1 row(s) from the central hub.
```
**17 s**, both followers, byte-identical ciphertext and `updated_utc`, **decrypt-verified on
both**. The first pass's `hub-gate-smoke-2` and the `hub-gate-smoke-1` tombstone survived the
image swap unchanged on all three nodes.
## Defects found
**None in ScadaBridge product code.** One library defect, found by this gate and fixed
forward; one defect in the *gate method*, caught before any result was recorded.
**Library: the hub logged denials only at Information** (`ZB.MOM.WW.Secrets.Replicator.Grpc`
0.4.0). Found by check 4's first pass; detailed above. Fixed at the library and shipped as
**0.4.1** (`scadaproj` main `c86cead`): `SecretsHubAuthInterceptor` now emits a rate-limited
`Warning` per denial window, broken down by cause, with the first denial after startup or a
quiet window warning immediately. Wire behaviour is byte-identical — the refusal is still one
uniform `Unauthenticated` with one detail. ScadaBridge took the bump in `6d38e89b` (all five
`ZB.MOM.WW.Secrets*` pins moved together; build 0 warnings, secrets wiring + hub-mapping pins
31/31), and check 4 was re-run in full against it.
**Gate method: host-side seeding of a bind-mounted SQLite store is not coherent with the
running container** (Docker Desktop for macOS). The first attempt at check 1 seeded central-a's
store with the CLI running natively on the host. The row was visible to the host and to a
fresh helper container, but the **running node never saw it**: four consecutive sweeps
returned an empty manifest, no `GetSecrets` was ever issued, and a `wal_checkpoint(TRUNCATE)`
did not help. Restarting central-a then **lost the row entirely** — it was gone from both the
host and container views. Cause is the virtiofs/page-cache boundary between the macOS host
and the Linux guest: SQLite's WAL and `-shm` mappings are not shared across it.
Corrected by doing **every** store access — seed, read, delete — from a throwaway container
on the same guest kernel, which is what the sibling OtOpcUa gate already did. The rig was
returned to an empty-store baseline before check 1 was re-run. Recorded because the failure
mode is a convincing false negative: it looks exactly like "the hub is broken".
## Residuals
1. **The hub client dials a SINGLE endpoint — no failover.** Known and documented on the
branch. Observed live and directly contrasted on the same node in the same minute:
```
site-a-a [11:51:13 WRN] Central control-plane endpoint http://scadabridge-central-a:8083 is
unavailable; site site-a failed over to http://scadabridge-central-b:8083.
site-a-a [11:51:12 WRN] Secret hub sync failed; the node continues serving its local store …
```
`ScadaBridge:Communication:CentralGrpcEndpoints` is a list and failed over; the hub client
stalled. Survivable — the sweep is best-effort and the node keeps its full local
last-known-good store — but secrets stop converging until that one central node returns.
2. **The central pair does NOT converge with itself, and this makes residual 1 sharper than
it reads.** Both central nodes host the hub (verified: `central-b` answers, and refuses an
unauthenticated call identically). But `central-b`'s store stayed **empty** for the whole
run while `central-a` held both secrets:
```
central-b, correct bearer, 11:59:32Z: {} ← empty manifest, authenticated
central-node-b (store): (empty)
```
Nothing replicates central-a ↔ central-b in `Mode=Grpc` — the topology is pull-only
*central → sites*. So a hypothetical failover to `central-b` would not merely stall, it
would succeed against an empty hub. By the reconciler's pull-only algorithm that is not
data loss (a name absent from the manifest is simply not pulled, never deleted), but it
*is* a silent stop to convergence, and a deployment that authors a secret on the central
node the sites do **not** dial will see it reach nobody. **Not live-tested here** —
asserted from the algorithm plus the observed empty manifest. Any production enablement
needs an answer for how the central pair itself converges.
3. **Both site-a nodes dial the same central node.** Not a rig shortcut — it is the only
shape the client supports (residual 1). Worth restating so the run is not read as having
exercised two independent hub sources.
4. **The `/admin/secrets` write path was not exercised.** Seeding was CLI-into-central's-store.
For this topology that is the same path (see [Method](#method)), so it is a coverage note,
not a gap in the result — but the page's own Blazor/authz behaviour is unproven by this
gate. `ScadaBridge#22` covered it separately.
5. **Delete-while-a-follower-is-offline was not tested.** Check 3 restarts the pair *after*
both already held the tombstone. The harder resurrection case — deleting at central while
a follower is down, then bringing it back holding a live copy — is covered by the
library's last-writer-wins tests but not live here. Under a pull-only hub it is
structurally safer than in a bidirectional topology (the follower can never push its stale
live row back), which is why it was not prioritised.
6. **Replication stays default-OFF in the product.** `Secrets:Replication:Enabled` is `false`
and `Mode` is `SqlServer` in the shipped `appsettings.json`. It is enabled on this docker
rig only, on four of eight nodes, with a dev KEK and a dev token. Production enablement
additionally needs a real KEK supplied out of band and a real token — never committed, and
never a `${secret:}` reference.
7. **Checks 2 and 3 ran on 0.4.0 only.** 0.4.1 changes what the hub writes to its own log on
a denied call and nothing else — no wire change, no store change, no sweep change — so
re-running the outage and tombstone drills would have re-proven code the bump did not
touch. Convergence *was* re-smoked on the 0.4.1 image (17 s, decrypt-verified on both
followers), and the first pass's live secret and tombstone survived the image swap
unchanged, so the 0.4.1 image is not merely assumed to replicate.
## Follow-ups
- **File against ScadaBridge:** endpoint failover for the hub client, and/or a documented
answer for how the central pair's own stores converge (residual 2). Both are the same
underlying question — "which central node is authoritative for secrets".
- **Reading the new denial warning:** `N` is a **per-window delta**, not a running total.
Summing across lines gives the true count; quoting the last line understates it. Worth
knowing before anyone builds an alert on the `The gRPC secrets hub denied` anchor.
## Conclusion
The pull-only gRPC secrets hub **works, and works the way the branch says it does**:
convergence is one sweep tick, ciphertext is relayed verbatim so last-writer-wins ordering
survives the hop, a site node boots and serves its full last-known-good store with the entire
central pair stopped, convergence resumes unaided when central returns, tombstones propagate
and do not resurrect across a pair restart, the auth gate refuses uniformly and now *says so*
at `WRN` within a second of the first attempt, and no token, KEK or plaintext — including the
rejected credentials the negatives presented — reaches any log on any of the eight nodes.
The one clause that failed on the first pass was a library property, not a branch one. It was
fixed where it belonged, ScadaBridge took the bump, and check 4 was re-run in full rather than
argued around. **4/4 — merged to `main`.**
**Unchanged by this gate:** `Secrets:Replication:Enabled` remains **default-false in the
product**, with `Mode` defaulting to `SqlServer`. It is enabled on this docker rig only, on
four of eight nodes, with a dev KEK and a dev token. Production enablement additionally
requires a real KEK supplied out of band, a real hub token, and an answer to residual 2.