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
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.SqlServerandZB.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
Replicatorname is honest and partition tolerance is available without Akka.SecretLastWriterWinsextracted 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.ReplicatingSecretStoreadded. Discovered during the build that nothing ever calledISecretReplicator.PublishAsync— the seam was inert. A store decorator fixes every write path.ISecretReplicationHubadded, 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/SqliteSecretsStoreMigratorsemantics:schema_versionsingle-row table +secrettable,CurrentVersion = 1, idempotentIF NOT EXISTSDDL, refuse a newer on-disk version. Column set identical to SQLite; usevarbinary(max)for the 6 crypto BLOBs,nvarcharfor text, and store timestamps as ISO-8601 ("O") text soStoredSecretround-trips byte-identically to the SQLite path (avoidsdatetimeoffsetprecision drift in tests). - Add
Microsoft.Data.SqlClienttoDirectory.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 theSqliteSecretStoreTestscases 1:1, incl. the G-8ApplyRewrap*cases)
Notes:
- Implement all 7 members:
GetAsync,UpsertAsync(revision bump —MERGEor insert/ON CONFLICT-equivalent viaUPDATE+INSERTunder a transaction),DeleteAsync(tombstone + revision bump),ListAsync(metadata projection — never the crypto columns),GetManifestAsync,ApplyReplicatedAsync(LWW underIsolationLevel.Serializable, applied verbatim, no revision bump),ApplyRewrapAsync(UPDATE the 4 wrap columns +kek_idonly; 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(addSecretsStoreKind Store=Sqlitedefault; addstring? SqlServerConnectionString) - Create:
src/ZB.MOM.WW.Secrets/SecretsStoreKind.cs(enumSqlite | SqlServer) - Modify:
src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs(branch theISecretStore+ migrator registration onStore; keep SQLite the default so HistorianGateway/mxaccessgw are unaffected) - Modify:
src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.csif 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 eachStorevalue)
Notes:
- Introduce a tiny
ISecretsStoreMigratorseam (both migrators implement it) so the hosted service + CLI stay store-agnostic, OR switch onStoreat registration. Prefer the seam — cleaner. - The connection string should itself be deliverable via
${secret:}/ asecret: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: documentSecrets:Store = SqlServer+ shared-KEK requirement + "runrewrap-allonce 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 : ISecretReplicator—PublishAsyncbroadcasts the encryptedStoredSecretto peers (a cluster-aware router / distributed pub-sub topic).- An anti-entropy actor (cluster singleton or per-node) that periodically exchanges
GetManifestAsyncdigests and pulls newer/missing rows viaApplyReplicatedAsync(LWW); tombstones propagate deletes; a retention window bounds tombstone GC. StoredSecretserialization (ciphertext only — never the KEK) + anISecretActorAccessorwiring.- 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-allper node.