Files
scadaproj/docs/plans/2026-07-17-secrets-g7-sqlserver-store.md
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

8.3 KiB

G-7 (Option A) — Shared SQL-Server ISecretStore Implementation Plan

EXECUTED 2026-07-18 — and the scope was widened on the user's instruction. This plan covered Option A only, with Option B (Akka) deferred. The user directed that both be built, packaged as ZB.MOM.WW.Secrets.Replicator.SqlServer and ZB.MOM.WW.Secrets.Replicator.AkkaDotNet — so the "Deferred phase-2" section below is also built, and the naming here (SqlServer/ inside the core package; ZB.MOM.WW.Secrets.Akka) is superseded by two standalone packages.

Other deltas from what was planned, all deliberate:

  • Two SQL-Server topologies, not one. Shared-store (as planned) plus a local-store-with-hub mode, so the package's Replicator name is honest and partition tolerance is available without Akka.
  • SecretLastWriterWins extracted to Abstractions. The plan flagged Task 2 high-risk because the LWW semantics "MUST match SQLite exactly"; a shared predicate makes that structural instead of a review obligation.
  • ReplicatingSecretStore added. Discovered during the build that nothing ever called ISecretReplicator.PublishAsync — the seam was inert. A store decorator fixes every write path.
  • ISecretReplicationHub added, so the hub sweep is testable offline rather than requiring a live SQL Server for every convergence case.
  • Anti-entropy is bidirectional. Pull-only would strand a write made while a peer was down: the peer never learns the name exists, so it can never ask for it.

Verified: 164 pass / 1 skip / 0 warnings, including 15 live tests against a real SQL Server 2022 and a 9-test in-process 2-node Akka cluster. See components/secrets/GAPS.md §G-7.

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Add a shared SQL-Server ISecretStore provider to ZB.MOM.WW.Secrets so the Akka-clustered apps (ScadaBridge, OtOpcUa) can back their secrets with one shared, ciphertext-only database — every node sees the same secrets, no replication code.

Architecture: Mirror the existing SqliteSecretStore + SqliteSecretsStoreMigrator in T-SQL behind the unchanged ISecretStore seam. Select the store in AddZbSecrets from a new SecretsOptions.Store enum (SQLite stays the default). The KEK stays per-node/out-of-DB, so the DB holds only ciphertext. See the design + fork rationale in 2026-07-17-secrets-g7-clustered-replication-design.md.

Tech Stack: .NET 10, Microsoft.Data.SqlClient, xUnit + Shouldly, env-gated live SQL tests (LocalDB or a mcr.microsoft.com/mssql/server container), matching the app live-test idiom.

Precondition: G-8 is merged — ISecretStore.ApplyRewrapAsync exists and the SQL-Server store must implement it. rewrap-all then runs once against the shared store.


Task 1: SQL-Server schema + migrator

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Task 2 depends on it)

Files:

  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsSchema.cs
  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretsStoreMigrator.cs
  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SecretsSqlServerConnectionFactory.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretsStoreMigratorTests.cs (env-gated)

Notes:

  • Mirror SqliteSecretsSchema / SqliteSecretsStoreMigrator semantics: schema_version single-row table + secret table, CurrentVersion = 1, idempotent IF NOT EXISTS DDL, refuse a newer on-disk version. Column set identical to SQLite; use varbinary(max) for the 6 crypto BLOBs, nvarchar for text, and store timestamps as ISO-8601 ("O") text so StoredSecret round-trips byte-identically to the SQLite path (avoids datetimeoffset precision drift in tests).
  • Add Microsoft.Data.SqlClient to Directory.Packages.props + the core csproj.
  • Gate live tests on an env var (e.g. SECRETS_SQLSERVER_CONNSTR); skip cleanly when unset.

Task 2: SqlServerSecretStore : ISecretStore

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 1)

Files:

  • Create: src/ZB.MOM.WW.Secrets/SqlServer/SqlServerSecretStore.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/SqlServer/SqlServerSecretStoreTests.cs (env-gated; port the SqliteSecretStoreTests cases 1:1, incl. the G-8 ApplyRewrap* cases)

Notes:

  • Implement all 7 members: GetAsync, UpsertAsync (revision bump — MERGE or insert/ON CONFLICT-equivalent via UPDATE+INSERT under a transaction), DeleteAsync (tombstone + revision bump), ListAsync (metadata projection — never the crypto columns), GetManifestAsync, ApplyReplicatedAsync (LWW under IsolationLevel.Serializable, applied verbatim, no revision bump), ApplyRewrapAsync (UPDATE the 4 wrap columns + kek_id only; no revision/updated_utc change).
  • Fully parameterized commands; same "created_utc/created_by preserved on overwrite" semantics as SQLite. This is high-risk because the LWW + rewrap semantics MUST match SQLite exactly (a cluster reconciles across both if a node ever migrates).

Task 3: DI store-selection wiring

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (depends on Task 2)

Files:

  • Modify: src/ZB.MOM.WW.Secrets/SecretsOptions.cs (add SecretsStoreKind Store = Sqlite default; add string? SqlServerConnectionString)
  • Create: src/ZB.MOM.WW.Secrets/SecretsStoreKind.cs (enum Sqlite | SqlServer)
  • Modify: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs (branch the ISecretStore + migrator registration on Store; keep SQLite the default so HistorianGateway/mxaccessgw are unaffected)
  • Modify: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs if it hard-refs the SQLite migrator type (abstract behind a shared migrator seam or a switch)
  • Test: tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs (assert the correct store type is resolved for each Store value)

Notes:

  • Introduce a tiny ISecretsStoreMigrator seam (both migrators implement it) so the hosted service + CLI stay store-agnostic, OR switch on Store at registration. Prefer the seam — cleaner.
  • The connection string should itself be deliverable via ${secret:} / a secret: ref (consumers already do this for other connstrings).

Task 4: Docs + adoption notes

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 3

Files:

  • Modify: README.md (Clustered deployments: document Secrets:Store = SqlServer + shared-KEK requirement + "run rewrap-all once against the shared store")
  • Create: docs/operations/shared-sql-store.md (operator setup: provision the DB, grant, set the connstr via a secret ref, confirm the same KEK on every node)
  • Modify: components/secrets/GAPS.md (G-7 → "Option A built"; note Option B deferred)

Notes: No app changes here — adoption in ScadaBridge/OtOpcUa is a follow-on (flip Secrets:Store, supply the connstr + shared KEK). Capture that as a checklist, not code.


Deferred phase-2 (NOT built here): Option B — ZB.MOM.WW.Secrets.Akka

Build only when an app declares it must resolve secrets while partitioned from the shared store. Shape (for when it's warranted):

  • New package ZB.MOM.WW.Secrets.Akka (net10; refs Akka.Cluster + Abstractions).
  • AkkaSecretReplicator : ISecretReplicatorPublishAsync broadcasts the encrypted StoredSecret to peers (a cluster-aware router / distributed pub-sub topic).
  • An anti-entropy actor (cluster singleton or per-node) that periodically exchanges GetManifestAsync digests and pulls newer/missing rows via ApplyReplicatedAsync (LWW); tombstones propagate deletes; a retention window bounds tombstone GC.
  • StoredSecret serialization (ciphertext only — never the KEK) + an ISecretActorAccessor wiring.
  • Validation: a live 2-node ScadaBridge/OtOpcUa rig proving write-on-A → resolve-on-B, delete propagation, and partition-heal resync — a G-2-class live gate.
  • KEK constraint identical to Option A: same master KEK on every node; rewrap-all per node.