Files
scadaproj/docs/plans/2026-07-18-secrets-0.2.0-upgrade-and-clustering.md
T
Joseph Doherty 62a96e5f22 docs(secrets): reconcile 0.2.1 adoption - what was proven vs merely built
- All 4 apps on 0.2.1 (local branches, unpushed).
- Records the corrected security story: the version bump closed NO advisory (all four
  repos already resolved patched 2.1.12); the real live vulnerability was in ScadaBridge,
  masked by a NuGetAuditSuppress, and was fixed by separate work.
- Records the upstream 0.2.0 inert-Akka-replicator defect, its root cause (DI extensions
  with no container-building test - third instance of that class), and the 0.2.1 fix.
- Marks clustered topology WIRED-but-default-OFF and explicitly NOT live-validated;
  Task 9 remains open and the topology is not 'adopted' until it passes.
2026-07-18 12:21:31 -04:00

403 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 `0.2.0` Upgrade + Clustered Topology Adoption — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Move all four family apps from `ZB.MOM.WW.Secrets` `0.1.2` to `0.2.0`, and adopt a **clustered secrets topology** in the two Akka-clustered apps (OtOpcUa, ScadaBridge) so a secret written on one node is resolvable on every node that needs it.
**Architecture:** No library construction — `0.2.0` is built, published, and restore-verified on the Gitea feed (5 packages). Two distinct workstreams: (A) a **pure version bump** for all four apps, which is behaviour-preserving and independently valuable (it picks up the G-8 KEK-rotation surface and a **high-severity transitive security fix**); (B) **topology adoption** for the two clustered apps, which is *configuration + one DI call*, no product-code change. The topology choice is **not the same for both apps** — see §2, this is the load-bearing design point.
**Tech Stack:** .NET 10, C#, `ZB.MOM.WW.Secrets{,.Abstractions,.Ui}` @ `0.2.0`, plus `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` (OtOpcUa) / `ZB.MOM.WW.Secrets.Replicator.SqlServer` (ScadaBridge), all from the `dohertj2-gitea` feed. Akka.NET **1.5.62** in both clustered apps — an exact match with the replicator package's Akka dependency.
**Predecessors:** [`2026-07-16-secrets-adoption-design.md`](2026-07-16-secrets-adoption-design.md) (the adoption template — §5 rewritten by this work), the three executed per-app adoption plans, and [`2026-07-17-secrets-g7-clustered-replication-design.md`](2026-07-17-secrets-g7-clustered-replication-design.md) (the fork analysis, now a *deployment* decision rather than a build one).
---
## 1. Why bump at all
> **⚠️ CORRECTED TWICE during execution, 2026-07-18 — read this before citing the bump's value.**
> The original text claimed the bump "closes a high-severity advisory in all four apps." A first
> correction narrowed that to "two apps." **Both were wrong.** Measured by A/B against the `0.1.2`
> baseline in every repo: **all four already resolved the patched `SQLitePCLRaw.lib.e_sqlite3
> 2.1.12` before this bump.** The Secrets `0.2.0` upgrade delivers **no security change to any
> app.** The lesson — the same one G-7's post-build review taught — is that a plausible mechanism
> is not evidence. Both wrong claims came from reasoning about what `0.2.0`'s nuspec *should*
> deliver instead of measuring what each repo *already resolved*.
`0.2.0` does declare a direct dependency on the patched `SQLitePCLRaw.lib.e_sqlite3 2.1.12`
(GHSA-2m69-gcr7-jv3q, high). That is real — it is simply **redundant everywhere**, because each
repo had already arrived at ≥`2.1.12` by another route:
| App | How it already resolved ≥ 2.1.12 before the bump | Security delta from this bump |
|---|---|---|
| HistorianGateway | Direct pin `SQLitePCLRaw.lib.e_sqlite3 3.50.3` (cites a *different* CVE, CVE-2025-6965) | **None** |
| OtOpcUa | `SQLitePCLRaw.bundle_e_sqlite3` pinned `2.1.12` in `Directory.Packages.props` | **None** |
| MxAccessGateway | Transitively from the pre-existing `ZB.MOM.WW.Auth.ApiKeys 0.1.5` reference | **None** (A/B-verified on the 0.1.2 baseline) |
| ScadaBridge | Transitively from `ZB.MOM.WW.Auth.ApiKeys 0.1.5` (commit `50d79ed1`) | **None** (A/B-verified) |
**The honest justification for Tasks 14 is therefore version hygiene plus a precondition:** the
replication packages in Tasks 59 are `0.2.0` artifacts and cannot be consumed on `0.1.2`, and
`0.2.0` exposes the G-8 KEK-rotation surface (`secret rewrap-all`) the runbooks document but no app
can currently run. That is a legitimate reason to land them — but it is **weaker than originally
stated**, and it means Tasks 14 are *not* independently valuable in the way this plan first
claimed. If the clustering work is abandoned, the bumps buy only currency and rotation access.
### The real SQLite finding — and it was a live vulnerability
Chasing the suppressions turned up something the version bump had nothing to do with. **Fixed
2026-07-18**, in its own commits, separate from the Secrets bump:
**ScadaBridge was shipping a live high-severity vulnerability, masked by its own suppression.**
The earlier A/B only checked the *Host* project, which resolved a patched `2.1.12` transitively via
`Auth.ApiKeys`. Every *other* SQLite consumer — `AuditLog`, `SiteRuntime`, `StoreAndForward`,
`SiteEventLogging` and 11 test projects — still resolved the vulnerable **`2.1.11`**, and the
`NuGetAuditSuppress` hid NU1903 for all of them. Proven by removing the suppression and forcing a
restore: 12+ projects immediately reported NU1903.
Both suppressions rested on false premises: mxaccessgw's said *"no patched e_sqlite3 release exists
yet"*; ScadaBridge's said *"the only patched native lib is the SQLitePCLRaw 3.x line."* **`2.1.12`
patches this advisory within the 2.1.x line** — verified empirically (suppression removed + pin in
place → forced restore reports no NU1903). HistorianGateway's separate `3.50.3` pin cites
**CVE-2025-6965**, a *different* advisory; that is the likely source of the "only 3.x" confusion.
**Fix applied:** explicit `PackageReference` to `SQLitePCLRaw.lib.e_sqlite3 2.1.12` in every
SQLite-consuming project, suppressions deleted in both repos (ScadaBridge `2a0faeab`, mxaccessgw
`2f0cfe3`). HistorianGateway and OtOpcUa needed nothing — genuinely fixed already, no suppressions.
**Rejected alternative:** `CentralPackageTransitivePinningEnabled`, which clears it in one line but
makes every central version a ceiling for transitive resolution too — immediately demanding bumps
to `Google.Protobuf`, `Grpc.Net.Client`, `Microsoft.Data.SqlClient` and `Newtonsoft.Json`. That is
a gRPC/data-access change to a production SCADA platform and belongs in its own reviewed commit,
not smuggled in under a security fix. **Recorded as a genuine follow-up** — it is the better
long-term posture.
Verified: ScadaBridge 55 projects build 0 warn / 0 error, **1108 tests pass across the SQLite
layer**; mxaccessgw 0 warn / 0 error with test results identical to baseline.
Tasks 14 remain four independent, single-file, behaviour-preserving edits in four separate repos
**fully parallelizable**.
### Build/test expectations — corrected against reality
The original text expected "0 warnings" everywhere. Measured, on the `0.1.2` baseline:
- **OtOpcUa** emits **861** pre-existing warnings (`OTOPCUA0001` / xUnit analyzers). Identical count before and after the bump. Do not expect 0.
- **HistorianGateway**'s full-solution restore **fails** on an AngleSharp `NU1902` advisory promoted to an error by `TreatWarningsAsErrors` — pre-existing, reproduced on the unmodified base branch.
- **MxAccessGateway** has no `src/MxGateway.sln`; the solutions are `src/ZB.MOM.WW.MxGateway.slnx` and `src/ZB.MOM.WW.MxGateway.NonWindows.slnx` (the macOS-safe subset omitting the x86 net48 worker). **`CLAUDE.md`'s recorded build command is stale** and should be corrected.
- Several suites have pre-existing environment-gated failures on macOS (live-historian DNS, Unix-domain-socket path length, shared Docker-fixture contention). **A/B against the baseline before attributing any failure to the bump.**
## 2. The topology fork — and why the two clustered apps resolve it differently
Verified against both repos on 2026-07-18. This is the single most important section; getting it wrong produces a cluster that looks healthy and silently fails to converge.
### OtOpcUa → **Akka peer-to-peer** (recommended)
- OtOpcUa is **one Akka cluster**. Every node shares a single `seed-nodes` list, so `DistributedPubSub` reaches every member. The app **already uses `DistributedPubSub` heavily** in product code (`DriverHostActor.cs:419` caches the mediator; driver-health, alarm, and deployment-ack topics all ride it), so the transport is proven in-app, not newly introduced.
- OtOpcUa registers its `ActorSystem` **in DI via `Akka.Hosting`** — which is exactly what `SecretReplicationActorProvider` requires.
- **Driver-role nodes resolve Layer-B driver secrets** (Galaxy API key, OpcUaClient passwords) at driver-session-open. Those nodes should keep starting drivers when central SQL is unreachable; peer-to-peer replication serves reads from local state, so a partitioned driver node keeps working on last-known-good and re-converges on heal.
- *Best-practice fit:* matches the app's existing cluster-communication idiom, adds no new infrastructure dependency, and preserves partition tolerance for the node role that most needs it.
- **Alternative:** shared SQL store against the existing ConfigDb (`AddZbSecretsSqlServerStore`) — simpler (zero distributed failure modes, one `rewrap-all`), but couples every driver node's secret resolution to ConfigDb reachability. Prefer this **only** if you decide driver nodes should hard-fail rather than run on cached secrets during a ConfigDb outage.
### ScadaBridge → **SQL-Server hub mode** (recommended) — Akka *cannot* do the job here
- ScadaBridge is **hub-and-spoke: one central cluster + N site clusters, each a SEPARATE Akka cluster** with its own `seed-nodes` list (`AkkaHostedService.BuildHocon`, `seed-nodes = [...]` per node). Central↔site traffic goes through `CentralCommunicationActor`, not cluster membership.
- **Therefore `DistributedPubSub` is cluster-scoped and does not cross the central↔site boundary.** An Akka replicator would converge the central pair among themselves and each site pair among themselves, producing **independent secret islands that never reconcile** — while every node reports healthy. This is the failure mode to avoid, and it is why "integrate with Akka" is the wrong answer for this app.
- Hub mode (`AddZbSecretsSqlServerReplication`) fits the topology exactly: each node keeps a **local** store and syncs bidirectionally with a shared central SQL hub. Site nodes keep resolving secrets **through a WAN outage** — which is the entire point of the hub-and-spoke design — and re-converge when the link returns.
- *Best-practice fit:* mirrors how ScadaBridge already treats central SQL (config, audit forwarding) — authoritative when reachable, locally buffered when not.
- **Alternative:** shared SQL store (`AddZbSecretsSqlServerStore`) — one store, no reconciliation, but a site that loses the WAN loses secret resolution past the resolver's short TTL cache. **Not recommended**: it contradicts the site-autonomy premise of the architecture.
### HistorianGateway, MxAccessGateway → **version bump only**
Both are single-box, non-clustered. No replication package, no topology. Tasks 12.
## 3. Constraints that apply to every topology
1. **Same KEK on every node — non-negotiable.** Rows are ciphertext-only; a node whose master key differs stores replicated rows fine and then **fails closed on every resolve** with a `kek_id` mismatch. It reads like data corruption but is a deployment error. Verify with the digest check in [`clustered-secrets.md`](../../ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md) §0 **before** enabling replication.
2. **KEK rotation does not replicate.** A re-wrap deliberately leaves `revision`/`updated_utc` untouched so it stays invisible to LWW. `rewrap-all` runs **once per independent store** — once for a shared store; once per node *and* against the hub in hub mode; once per node in Akka mode.
3. **`nuget.config` needs no change.** Both clustered repos already map `ZB.MOM.WW.Secrets.*` to `dohertj2-gitea` (OtOpcUa `NuGet.config:29`, ScadaBridge `nuget.config:29`), and that pattern already covers `ZB.MOM.WW.Secrets.Replicator.SqlServer` / `.Replicator.AkkaDotNet`. **Verified** — do not add redundant patterns.
4. **Do not reconfigure Data Protection.** Unchanged from the original adoption: secrets envelope crypto is independent of DP, and touching DP risks invalidating cookies/hub tokens.
---
## Task 1 — HistorianGateway: bump `0.1.2` → `0.2.0`
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 2, Task 3, Task 4 (different repos entirely)
**Files:**
- Modify: `~/Desktop/HistorianGateway/src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj:37-38`
**Steps:**
1. This repo does **not** use CPM for these — versions are inline on the `PackageReference`. Change both:
```xml
<PackageReference Include="ZB.MOM.WW.Secrets" Version="0.2.0" />
<PackageReference Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.0" />
```
2. Restore + build:
```bash
cd ~/Desktop/HistorianGateway && dotnet build ZB.MOM.WW.HistorianGateway.slnx
```
**Expected:** succeeds, 0 warnings. `0.2.0` is API-compatible with `0.1.2` for everything this app uses.
3. Confirm the security fix actually landed — this is the *point* of the task, so verify it rather than assuming:
```bash
dotnet list ZB.MOM.WW.HistorianGateway.slnx package --include-transitive | grep -i sqlitepclraw
```
**Expected:** `SQLitePCLRaw.lib.e_sqlite3` resolves to `2.1.12` (not `2.1.11`).
4. Run the offline suite: `dotnet test ZB.MOM.WW.HistorianGateway.slnx`
**Expected:** green. **Known pre-existing:** an AngleSharp `NU1902` advisory warning in the test project — unrelated to this change; do not chase it, but confirm it is the *only* warning and that it predates the bump (`git stash` + rebuild to compare if unsure).
5. Commit:
```bash
git add -A && git commit -m "chore(secrets): bump ZB.MOM.WW.Secrets 0.1.2 -> 0.2.0 (SQLitePCLRaw advisory fix + KEK rotation)"
```
---
## Task 2 — MxAccessGateway: bump `0.1.2` → `0.2.0`
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 1, Task 3, Task 4
**Files:**
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj:20-22`
**Steps:**
1. Set all three inline versions to `0.2.0` (`ZB.MOM.WW.Secrets`, `.Abstractions`, `.Ui`).
2. Build: `cd ~/Desktop/MxAccessGateway && dotnet build src/MxGateway.sln` → **Expected:** 0 warnings.
3. Verify the transitive pin as in Task 1 step 3.
4. Test: `dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj` → **Expected:** green (no MXAccess needed — fake worker).
5. Commit with the same message shape as Task 1.
---
## Task 3 — OtOpcUa: bump `0.1.2` → `0.2.0` (CPM)
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 1, Task 2, Task 4
**Files:**
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props:135-137`
**Steps:**
1. OtOpcUa **is** on Central Package Management — edit the three `<PackageVersion>` rows to `0.2.0`. Consuming csprojs carry bare `<PackageReference>` with no `Version`; **do not** add versions there.
2. Build: `cd ~/Desktop/OtOpcUa && dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **Expected:** 0 warnings (repo builds under `TreatWarningsAsErrors`).
3. Verify the transitive pin as in Task 1 step 3.
4. Test: `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → **Expected:** green.
5. Commit. Branch: `feat/secrets-0.2.0` off `origin/master` (**OtOpcUa's default is `master`**).
---
## Task 4 — ScadaBridge: bump `0.1.2` → `0.2.0` (CPM)
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 1, Task 2, Task 3
**Files:**
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props:89-91`
**Steps:**
1. Edit the three `<PackageVersion>` rows to `0.2.0`.
2. Build: `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx` → **Expected:** 0 warnings.
3. Verify the transitive pin as in Task 1 step 3.
4. Test: `dotnet test ZB.MOM.WW.ScadaBridge.slnx`
**Expected:** green except the **known pre-existing** AngleSharp `NU1902` break in `CentralUI.Tests`. Confirm it predates the bump; do not fix it here.
5. Commit. Branch: `feat/secrets-0.2.0` off `origin/main`.
---
> **Checkpoint — review after Tasks 14.** All four apps are on `0.2.0` and the advisory is closed family-wide. This is a coherent, independently shippable slice: FF-merge and push each repo before starting Task 5. If the topology work is deferred, the plan still delivered its security value.
---
## Task 5 — OtOpcUa: add the Akka replicator package
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 7 (different repo)
**Depends on:** Task 3
**Files:**
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props` (ZB.MOM block, beside the rows edited in Task 3)
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`
**Steps:**
1. Add the CPM version row:
```xml
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.0" />
```
2. Add a bare reference in the **Host** csproj only (the Host owns the actor system and the secrets registration; driver projects keep referencing only `.Abstractions`):
```xml
<PackageReference Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" />
```
3. Restore: `dotnet restore ZB.MOM.WW.OtOpcUa.slnx`
**Expected:** resolves from `dohertj2-gitea` via the existing `ZB.MOM.WW.Secrets.*` mapping. It pulls `Akka.Cluster.Tools 1.5.62` — **the exact version OtOpcUa already pins**, so expect no downgrade/conflict. If NU1109 appears, stop: that means the Akka pins have drifted and the plan's premise needs rechecking.
4. Build → **Expected:** 0 warnings, nothing consumes the package yet.
5. Commit.
---
## Task 6 — OtOpcUa: wire Akka replication + shared-KEK config
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Depends on:** Task 5
> **High-risk because** it changes which `ISecretStore` implementation the container resolves on **every** node, including driver-role nodes that have no auth/AdminUI. A mistake here surfaces as drivers failing to open sessions, not as a test failure.
**Files:**
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (the existing unconditional `AddZbSecrets` call site added by the G-2 adoption)
- Modify: `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json`
- Test: `~/Desktop/OtOpcUa/tests/.../Host.Tests/` — DI-resolution test (see step 4)
**Steps:**
1. **Replace** the runtime registration — `AddZbSecretsAkkaReplication` calls `AddZbSecrets` internally, so calling both would double-register:
```csharp
// was: builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddZbSecretsAkkaReplication(builder.Configuration, "Secrets", "Secrets:Replication");
```
Using: `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection`.
**Leave the pre-host expander untouched** — it builds a throwaway container that must stay a plain local SQLite store. Replication is a *runtime* concern; the bootstrap provider runs before the actor system exists.
2. Merge the serializer HOCON into the actor-system config so replication messages use the package's `SerializerWithStringManifest` rather than falling back to a default serializer:
```csharp
// where the Akka HOCON is assembled:
config = config.WithFallback(AkkaSecretsReplication.SerializationConfig);
```
3. Add the config block to `appsettings.json` beside the existing `Secrets` section:
```json
"Secrets": {
"Replication": { "AnnounceInterval": "00:00:30", "ActorName": "zb-secret-replication" }
}
```
4. **Write a DI-resolution test before wiring** (this is the class of defect that shipped in the library itself — every unit test passed because nothing ever built a container):
```csharp
[Fact]
public void Secrets_store_resolves_as_the_replicating_decorator()
{
// Build the Host's service collection, then:
var store = provider.GetRequiredService<ISecretStore>();
Assert.IsType<ReplicatingSecretStore>(store);
// And the undecorated concrete store must still resolve — the decorator depends on it:
Assert.NotNull(provider.GetRequiredService<SqliteSecretStore>());
}
```
Run it first and confirm it **fails** before step 1's edit, passes after.
5. Build + full suite → **Expected:** 0 warnings, green.
6. **Verify the same KEK is on every node** before any multi-node run — per §3.1, use the digest check in `clustered-secrets.md` §0. Do not skip: a mismatch is silent until first resolve.
7. Commit.
---
## Task 7 — ScadaBridge: add the SQL-Server replicator package + hub schema
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5 (different repo)
**Depends on:** Task 4
**Files:**
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props`
- Modify: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj`
**Steps:**
1. Add CPM row + bare reference for `ZB.MOM.WW.Secrets.Replicator.SqlServer` @ `0.2.0`.
2. Restore + build → **Expected:** resolves via the existing mapping; pulls `Microsoft.Data.SqlClient 6.0.2`. ScadaBridge already uses SqlClient — **if NU1109 appears, the repo pins an older SqlClient and you must reconcile the pin before continuing.** Report rather than force.
3. **Do not hand-provision the hub schema.** `AddZbSecretsSqlServerReplication` registers `SqlServerHubMigrationHostedService`, which creates it idempotently at startup. The DB and a login with `CREATE TABLE`/`CREATE SCHEMA` rights must exist — see `clustered-secrets.md` for the provisioning SQL.
4. Commit.
---
## Task 8 — ScadaBridge: wire hub replication + per-node config
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Depends on:** Task 7
> **High-risk because** it introduces a background sweep that writes to a shared central database from every site node, and because a wrong `Secrets:SqlServer:ConnectionString` on a site node fails at startup (eager `Validate()`), taking the node down.
**Files:**
- Modify: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (existing `AddZbSecrets` call site)
- Modify: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json`
- Modify: the per-node docker appsettings under `~/Desktop/ScadaBridge/docker/`
**Steps:**
1. Replace the runtime registration (it calls `AddZbSecrets` internally — do not call both):
```csharp
builder.Services.AddZbSecretsSqlServerReplication(
builder.Configuration, "Secrets", "Secrets:SqlServer");
```
2. Config block — the connection string is itself a secret, so deliver it the way every other ScadaBridge connstr is delivered (a `${secret:}` token resolved by the pre-host expander, which runs *before* this registration binds):
```json
"Secrets": {
"SqlServer": {
"ConnectionString": "${secret:sql/scadabridge/secrets-hub}",
"SchemaName": "zbsecrets",
"SyncInterval": "00:00:30",
"SyncOnStartup": true
}
}
```
**Ordering gotcha:** seed `sql/scadabridge/secrets-hub` in the **local** store on every node first. The hub connstr cannot itself come from the hub — that is a bootstrap cycle. This is the one config value that must stay locally seeded.
3. Add the same DI-resolution test as Task 6 step 4, asserting `ReplicatingSecretStore`.
4. Build + suite → **Expected:** 0 warnings; green modulo the known AngleSharp break.
5. Verify the shared KEK across central + site nodes (§3.1).
6. Commit.
---
## Task 9 — Live convergence gate (both clustered apps)
**Classification:** high-risk
**Estimated implement time:** ~5 min (excluding rig setup)
**Parallelizable with:** none
**Depends on:** Task 6, Task 8
> This is the **G-2-class live gate** the G-7 design called for. The library's offline suite proves the algorithms (2-node in-process Akka cluster, 15 live SQL-Server tests); it cannot prove *this app's* wiring. Do not mark the topology adopted without it.
**Steps — run per app:**
1. Stand up 2 nodes (OtOpcUa: docker-dev rig; ScadaBridge: central pair, then a site node against the central hub).
2. **Write on node A**, via the `/admin/secrets` UI or the `secret` CLI.
3. **Resolve on node B.** Expected: the value appears within one sweep/announce interval (~30 s).
4. **Delete on A → confirm the tombstone propagates to B** and B no longer resolves it. (Resurrection is the classic anti-entropy bug; this is the case that catches it.)
5. **Partition test** — the reason each topology was chosen:
- OtOpcUa: stop node B's cluster link; confirm B still resolves its Layer-B driver secrets from local state; heal; confirm re-convergence.
- ScadaBridge: stop the site node's route to the central hub; confirm it still resolves; restore; confirm the sweep re-converges both directions.
6. **Negative KEK control** (proves fail-closed rather than silent-wrong): point one node at a *different* KEK, confirm it fails closed with a `kek_id` mismatch rather than returning a wrong plaintext. Revert.
7. Record results in [`components/secrets/GAPS.md`](../../components/secrets/GAPS.md) — including anything that did **not** pass.
---
## Task 10 — Docs reconcile
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Depends on:** Task 9
**Files:**
- Modify: `components/secrets/GAPS.md` (close the two Open items: version bump; topology adoption)
- Modify: `CLAUDE.md` (Secrets row: consumer pins `0.1.2` → `0.2.0`; record the adopted topology per app)
- Modify: `components/secrets/current-state/*/CURRENT-STATE.md` (per-app store/topology)
**Steps:** State what was **proven live** vs what was only built. If Task 9's partition or KEK-negative steps were skipped for lack of a rig, say so explicitly — an unproven claim in these docs is the exact failure mode [`component-status-claims-are-optimistic`](../../components/secrets/GAPS.md) already warns about.
---
## Rollback
Every task is a version/config change with a clean revert:
- Tasks 14: revert the version rows; `0.1.2` is still on the feed.
- Tasks 58: revert the `AddZbSecrets*Replication` call to plain `AddZbSecrets(config, "Secrets")` and drop the package reference. Local SQLite stores are untouched by replication — rows written while replication was on remain readable, since the KEK and schema are unchanged.