- 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.
26 KiB
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 (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 (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.2baseline in every repo: all four already resolved the patchedSQLitePCLRaw.lib.e_sqlite3 2.1.12before this bump. The Secrets0.2.0upgrade 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 what0.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 1–4 is therefore version hygiene plus a precondition: the
replication packages in Tasks 5–9 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 1–4 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 1–4 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
NU1902advisory promoted to an error byTreatWarningsAsErrors— pre-existing, reproduced on the unmodified base branch. - MxAccessGateway has no
src/MxGateway.sln; the solutions aresrc/ZB.MOM.WW.MxGateway.slnxandsrc/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-nodeslist, soDistributedPubSubreaches every member. The app already usesDistributedPubSubheavily in product code (DriverHostActor.cs:419caches 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
ActorSystemin DI viaAkka.Hosting— which is exactly whatSecretReplicationActorProviderrequires. - 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, onerewrap-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-nodeslist (AkkaHostedService.BuildHocon,seed-nodes = [...]per node). Central↔site traffic goes throughCentralCommunicationActor, not cluster membership. - Therefore
DistributedPubSubis 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 1–2.
3. Constraints that apply to every topology
- 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_idmismatch. It reads like data corruption but is a deployment error. Verify with the digest check inclustered-secrets.md§0 before enabling replication. - KEK rotation does not replicate. A re-wrap deliberately leaves
revision/updated_utcuntouched so it stays invisible to LWW.rewrap-allruns 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. nuget.configneeds no change. Both clustered repos already mapZB.MOM.WW.Secrets.*todohertj2-gitea(OtOpcUaNuGet.config:29, ScadaBridgenuget.config:29), and that pattern already coversZB.MOM.WW.Secrets.Replicator.SqlServer/.Replicator.AkkaDotNet. Verified — do not add redundant patterns.- 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:
- This repo does not use CPM for these — versions are inline on the
PackageReference. Change both:<PackageReference Include="ZB.MOM.WW.Secrets" Version="0.2.0" /> <PackageReference Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.0" /> - Restore + build:
Expected: succeeds, 0 warnings.
cd ~/Desktop/HistorianGateway && dotnet build ZB.MOM.WW.HistorianGateway.slnx0.2.0is API-compatible with0.1.2for everything this app uses. - Confirm the security fix actually landed — this is the point of the task, so verify it rather than assuming:
Expected:
dotnet list ZB.MOM.WW.HistorianGateway.slnx package --include-transitive | grep -i sqlitepclrawSQLitePCLRaw.lib.e_sqlite3resolves to2.1.12(not2.1.11). - Run the offline suite:
dotnet test ZB.MOM.WW.HistorianGateway.slnxExpected: green. Known pre-existing: an AngleSharpNU1902advisory 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). - Commit:
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:
- Set all three inline versions to
0.2.0(ZB.MOM.WW.Secrets,.Abstractions,.Ui). - Build:
cd ~/Desktop/MxAccessGateway && dotnet build src/MxGateway.sln→ Expected: 0 warnings. - Verify the transitive pin as in Task 1 step 3.
- Test:
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj→ Expected: green (no MXAccess needed — fake worker). - 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:
- OtOpcUa is on Central Package Management — edit the three
<PackageVersion>rows to0.2.0. Consuming csprojs carry bare<PackageReference>with noVersion; do not add versions there. - Build:
cd ~/Desktop/OtOpcUa && dotnet build ZB.MOM.WW.OtOpcUa.slnx→ Expected: 0 warnings (repo builds underTreatWarningsAsErrors). - Verify the transitive pin as in Task 1 step 3.
- Test:
dotnet test ZB.MOM.WW.OtOpcUa.slnx→ Expected: green. - Commit. Branch:
feat/secrets-0.2.0offorigin/master(OtOpcUa's default ismaster).
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:
- Edit the three
<PackageVersion>rows to0.2.0. - Build:
cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx→ Expected: 0 warnings. - Verify the transitive pin as in Task 1 step 3.
- Test:
dotnet test ZB.MOM.WW.ScadaBridge.slnxExpected: green except the known pre-existing AngleSharpNU1902break inCentralUI.Tests. Confirm it predates the bump; do not fix it here. - Commit. Branch:
feat/secrets-0.2.0offorigin/main.
Checkpoint — review after Tasks 1–4. All four apps are on
0.2.0and 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:
- Add the CPM version row:
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.0" /> - 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):<PackageReference Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" /> - Restore:
dotnet restore ZB.MOM.WW.OtOpcUa.slnxExpected: resolves fromdohertj2-giteavia the existingZB.MOM.WW.Secrets.*mapping. It pullsAkka.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. - Build → Expected: 0 warnings, nothing consumes the package yet.
- 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
ISecretStoreimplementation 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 unconditionalAddZbSecretscall 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:
- Replace the runtime registration —
AddZbSecretsAkkaReplicationcallsAddZbSecretsinternally, so calling both would double-register:Using:// was: builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); builder.Services.AddZbSecretsAkkaReplication(builder.Configuration, "Secrets", "Secrets:Replication");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. - Merge the serializer HOCON into the actor-system config so replication messages use the package's
SerializerWithStringManifestrather than falling back to a default serializer:// where the Akka HOCON is assembled: config = config.WithFallback(AkkaSecretsReplication.SerializationConfig); - Add the config block to
appsettings.jsonbeside the existingSecretssection:"Secrets": { "Replication": { "AnnounceInterval": "00:00:30", "ActorName": "zb-secret-replication" } } - 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):
Run it first and confirm it fails before step 1's edit, passes after.
[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>()); } - Build + full suite → Expected: 0 warnings, green.
- 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. - 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:
- Add CPM row + bare reference for
ZB.MOM.WW.Secrets.Replicator.SqlServer@0.2.0. - 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. - Do not hand-provision the hub schema.
AddZbSecretsSqlServerReplicationregistersSqlServerHubMigrationHostedService, which creates it idempotently at startup. The DB and a login withCREATE TABLE/CREATE SCHEMArights must exist — seeclustered-secrets.mdfor the provisioning SQL. - 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:ConnectionStringon a site node fails at startup (eagerValidate()), taking the node down.
Files:
- Modify:
~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs(existingAddZbSecretscall site) - Modify:
~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json - Modify: the per-node docker appsettings under
~/Desktop/ScadaBridge/docker/
Steps:
- Replace the runtime registration (it calls
AddZbSecretsinternally — do not call both):builder.Services.AddZbSecretsSqlServerReplication( builder.Configuration, "Secrets", "Secrets:SqlServer"); - 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):Ordering gotcha: seed"Secrets": { "SqlServer": { "ConnectionString": "${secret:sql/scadabridge/secrets-hub}", "SchemaName": "zbsecrets", "SyncInterval": "00:00:30", "SyncOnStartup": true } }sql/scadabridge/secrets-hubin 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. - Add the same DI-resolution test as Task 6 step 4, asserting
ReplicatingSecretStore. - Build + suite → Expected: 0 warnings; green modulo the known AngleSharp break.
- Verify the shared KEK across central + site nodes (§3.1).
- 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:
- Stand up 2 nodes (OtOpcUa: docker-dev rig; ScadaBridge: central pair, then a site node against the central hub).
- Write on node A, via the
/admin/secretsUI or thesecretCLI. - Resolve on node B. Expected: the value appears within one sweep/announce interval (~30 s).
- 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.)
- 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.
- Negative KEK control (proves fail-closed rather than silent-wrong): point one node at a different KEK, confirm it fails closed with a
kek_idmismatch rather than returning a wrong plaintext. Revert. - Record results in
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 pins0.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 already warns about.
Rollback
Every task is a version/config change with a clean revert:
- Tasks 1–4: revert the version rows;
0.1.2is still on the feed. - Tasks 5–8: revert the
AddZbSecrets*Replicationcall to plainAddZbSecrets(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.