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
This commit is contained in:
Joseph Doherty
2026-07-18 04:08:23 -04:00
parent e46060fada
commit dd0a846b64
55 changed files with 4848 additions and 51 deletions
@@ -2,7 +2,8 @@ namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation;
/// <c>ZB.MOM.WW.Secrets.Akka</c> (deferred) provides the real cluster implementation.
/// <c>ZB.MOM.WW.Secrets.Replication.Akka</c> (peer-to-peer over the cluster) and
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> (via a shared hub database) provide the real ones.
/// </summary>
public interface ISecretReplicator
{
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// Provisions the schema backing an <see cref="ISecretStore"/>. Implementations are idempotent —
/// running a migration repeatedly is safe — and refuse to run against a store whose on-disk schema
/// version is <em>newer</em> than the build supports, rather than silently downgrading it.
/// </summary>
/// <remarks>
/// This seam exists so the startup migration hosted service and the CLI stay store-agnostic: the
/// SQLite store, the shared SQL-Server store, and any future provider are all provisioned through
/// the same call, and swapping the store never touches the migration wiring.
/// </remarks>
public interface ISecretsStoreMigrator
{
/// <summary>Creates or updates the secret-store schema to the version this build supports.</summary>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task that completes when the schema is at the supported version.</returns>
/// <exception cref="SecretStoreMigrationException">
/// The store's schema version is newer than this build supports.
/// </exception>
Task MigrateAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>
/// The single definition of the last-writer-wins (LWW) ordering used to reconcile a secret row
/// against a peer's copy: newer <see cref="StoredSecret.UpdatedUtc"/> wins, and
/// <see cref="StoredSecret.Revision"/> breaks a timestamp tie.
/// </summary>
/// <remarks>
/// <para>
/// Every store's <see cref="ISecretStore.ApplyReplicatedAsync"/> and every replication transport
/// reconciler routes its "is the incoming row newer?" decision through here. Keeping one definition
/// is a correctness requirement, not a tidiness one: a cluster can mix stores (a node backed by the
/// shared SQL-Server store alongside nodes on local SQLite), and two implementations that disagree
/// about a tie would converge to <em>different</em> rows on different nodes — a silent split-brain
/// that no single-node test can catch.
/// </para>
/// <para>
/// The comparison is deliberately <b>strict</b>: a row that merely ties the local one is not newer
/// and is ignored. That makes replication idempotent — re-delivering a row a node already has is a
/// no-op rather than a write — so at-least-once transports (pub/sub redelivery, an anti-entropy
/// sweep racing a live broadcast) cost nothing and cannot flap a row back and forth.
/// </para>
/// </remarks>
public static class SecretLastWriterWins
{
/// <summary>
/// Returns <see langword="true"/> if a row at (<paramref name="incomingUpdatedUtc"/>,
/// <paramref name="incomingRevision"/>) should overwrite a local row at
/// (<paramref name="localUpdatedUtc"/>, <paramref name="localRevision"/>).
/// </summary>
/// <param name="incomingUpdatedUtc">The incoming row's last-updated timestamp.</param>
/// <param name="incomingRevision">The incoming row's revision.</param>
/// <param name="localUpdatedUtc">The local row's last-updated timestamp.</param>
/// <param name="localRevision">The local row's revision.</param>
/// <returns><see langword="true"/> if the incoming row is strictly newer.</returns>
public static bool IsNewer(
DateTimeOffset incomingUpdatedUtc,
long incomingRevision,
DateTimeOffset localUpdatedUtc,
long localRevision) =>
incomingUpdatedUtc > localUpdatedUtc ||
(incomingUpdatedUtc == localUpdatedUtc && incomingRevision > localRevision);
/// <summary>
/// Returns <see langword="true"/> if <paramref name="incoming"/> should overwrite the local row
/// described by <paramref name="local"/>.
/// </summary>
/// <param name="incoming">The manifest entry received from a peer.</param>
/// <param name="local">The corresponding local manifest entry.</param>
/// <returns><see langword="true"/> if the incoming entry is strictly newer.</returns>
public static bool IsNewer(SecretManifestEntry incoming, SecretManifestEntry local)
{
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(local);
return IsNewer(incoming.UpdatedUtc, incoming.Revision, local.UpdatedUtc, local.Revision);
}
}