diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
new file mode 100644
index 0000000..7f3e4fe
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs
@@ -0,0 +1,189 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Tally of an run.
+///
+/// Rows written (new inserts plus conflict winners).
+/// Rows a last-writer-wins (or override) decision rejected as not newer.
+///
+/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
+///
+/// Rows whose name already existed in the target store (won or lost).
+public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
+
+///
+/// Exports and imports documents — ciphertext-only movement of secret rows
+/// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an
+/// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK,
+/// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported.
+/// Both operations require a full (non-degraded) session: import needs the target cipher to
+/// re-wrap, and export is a data-movement operation that must not run half-configured.
+///
+public sealed class BundleService
+{
+ private readonly TimeProvider _timeProvider;
+
+ /// Creates the service.
+ /// Clock used to stamp ; defaults to .
+ public BundleService(TimeProvider? timeProvider = null) =>
+ _timeProvider = timeProvider ?? TimeProvider.System;
+
+ ///
+ /// Exports every secret in 's store to a ciphertext-only bundle at
+ /// , written atomically (temp file then move). Tombstones are excluded unless
+ /// is set.
+ ///
+ /// The full session to export from.
+ /// Destination bundle path.
+ /// When , tombstoned rows are exported too.
+ /// A token to cancel the operation.
+ /// The number of rows written to the bundle.
+ /// The session is degraded (no KEK).
+ public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ IMasterKeyProvider masterKey = session.MasterKey
+ ?? throw new InvalidOperationException(
+ "Cannot export a bundle from a degraded session (no KEK available).");
+
+ IReadOnlyList metadata =
+ await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
+
+ var entries = new List(metadata.Count);
+ foreach (SecretMetadata meta in metadata)
+ {
+ StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false);
+ if (row is null)
+ {
+ continue;
+ }
+
+ entries.Add(SecretBundleCodec.FromRow(row));
+ }
+
+ var bundle = new SecretBundle
+ {
+ ExportedUtc = _timeProvider.GetUtcNow(),
+ SourceKekId = masterKey.KekId,
+ Entries = entries,
+ };
+
+ await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false);
+ return entries.Count;
+ }
+
+ ///
+ /// Imports the bundle at into 's store.
+ ///
+ ///
+ /// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
+ /// matches its , otherwise it is
+ /// skipped (). A row whose name does not yet
+ /// exist is imported. A row whose name already exists is a conflict resolved by
+ /// when supplied, else by ;
+ /// the winner is written and the loser skipped ().
+ /// No plaintext is ever handled — the bundle is ciphertext only.
+ ///
+ /// The full session to import into.
+ /// Source bundle path.
+ /// The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be .
+ ///
+ /// Optional per-row decision for an existing row: given (existing, incoming), return
+ /// to take the incoming row. When , last-writer-wins decides.
+ ///
+ /// A token to cancel the operation.
+ /// A tally of the import.
+ /// The session is degraded (no KEK/cipher).
+ public async Task ImportAsync(
+ SecretsSession session,
+ string path,
+ IMasterKeyProvider? sourceKek,
+ Func? conflictOverride,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ IMasterKeyProvider targetKek = session.MasterKey
+ ?? throw new InvalidOperationException(
+ "Cannot import a bundle into a degraded session (no KEK available).");
+ ISecretCipher cipher = session.Cipher
+ ?? throw new InvalidOperationException(
+ "Cannot import a bundle into a degraded session (no cipher available).");
+
+ string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
+ SecretBundle bundle = SecretBundleCodec.Deserialize(json);
+
+ int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;
+
+ foreach (BundleEntry entry in bundle.Entries)
+ {
+ StoredSecret row = SecretBundleCodec.ToRow(entry);
+
+ // Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
+ if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal))
+ {
+ if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal))
+ {
+ row = cipher.Rewrap(row, sourceKek, targetKek);
+ }
+ else
+ {
+ skippedForeignKek++;
+ continue;
+ }
+ }
+
+ StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
+ if (existing is null)
+ {
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+ imported++;
+ continue;
+ }
+
+ conflicts++;
+ bool takeIncoming = conflictOverride is not null
+ ? conflictOverride(existing, row)
+ : SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
+
+ if (takeIncoming)
+ {
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+ imported++;
+ }
+ else
+ {
+ skippedOlder++;
+ }
+ }
+
+ return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts);
+ }
+
+ // Writes content to a sibling temp file then moves it over the target, so a reader never observes
+ // a half-written bundle and a crash mid-write cannot corrupt an existing bundle.
+ private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct)
+ {
+ string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".";
+ Directory.CreateDirectory(directory);
+ string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false);
+ File.Move(temp, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temp))
+ {
+ try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ }
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
new file mode 100644
index 0000000..0e65815
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs
@@ -0,0 +1,161 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Ciphertext-only export format for moving secret rows between deployment stores (cloning a
+/// deployment, staging a recovery). A bundle carries only the encrypted
+/// representation — never plaintext — so it is safe at rest and useless
+/// to anyone without the source KEK. Every array rides as a base64 string and
+/// every timestamp as an ISO-8601 , so a parsed bundle round-trips
+/// byte-identical to what was exported.
+///
+public sealed record SecretBundle
+{
+ /// The on-disk bundle format version (currently 1).
+ public int FormatVersion { get; init; } = 1;
+
+ /// When the bundle was exported (UTC).
+ public DateTimeOffset ExportedUtc { get; init; }
+
+ ///
+ /// The of the store the bundle was exported from — the KEK
+ /// an operator must supply to import into a store on a different KEK (so rows can be re-wrapped).
+ ///
+ public string SourceKekId { get; init; } = "";
+
+ /// The exported rows, each mirroring a with base64 crypto fields.
+ public IReadOnlyList Entries { get; init; } = [];
+}
+
+///
+/// One exported row — a faithful, ciphertext-only mirror of a . The six
+/// crypto BLOBs are carried as base64 strings and the name as its normalized string form; all other
+/// fields keep their native JSON representation so the row survives an export/parse round-trip.
+///
+/// The normalized secret name ().
+/// Optional human-readable description.
+/// The name.
+/// Base64 of the AES-256-GCM ciphertext.
+/// Base64 of the body nonce.
+/// Base64 of the body authentication tag.
+/// Base64 of the KEK-wrapped data-encryption key.
+/// Base64 of the DEK-wrap nonce.
+/// Base64 of the DEK-wrap authentication tag.
+/// Identifier of the KEK that wrapped the DEK.
+/// The row's monotonic revision.
+/// Whether the row is a tombstone.
+/// When the row was tombstoned, if it is deleted.
+/// When the row was first created (UTC).
+/// When the row was last updated (UTC).
+/// Principal that created the row, if known.
+/// Principal that last updated the row, if known.
+public sealed record BundleEntry(
+ string Name,
+ string? Description,
+ string ContentType,
+ string Ciphertext,
+ string Nonce,
+ string Tag,
+ string WrappedDek,
+ string WrapNonce,
+ string WrapTag,
+ string KekId,
+ long Revision,
+ bool IsDeleted,
+ DateTimeOffset? DeletedUtc,
+ DateTimeOffset CreatedUtc,
+ DateTimeOffset UpdatedUtc,
+ string? CreatedBy,
+ string? UpdatedBy);
+
+///
+/// Serializes and parses documents and converts between a
+/// row and its mirror. The conversion is
+/// lossless in both directions: ((row)) reproduces every
+/// field of byte-identically.
+///
+public static class SecretBundleCodec
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ };
+
+ /// Serializes to indented JSON.
+ /// The bundle to serialize.
+ /// The indented JSON document.
+ public static string Serialize(SecretBundle bundle)
+ {
+ ArgumentNullException.ThrowIfNull(bundle);
+ return JsonSerializer.Serialize(bundle, Options);
+ }
+
+ /// Parses a from its JSON representation.
+ /// The JSON document produced by .
+ /// The parsed bundle.
+ /// The JSON does not represent a bundle.
+ public static SecretBundle Deserialize(string json)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(json);
+ return JsonSerializer.Deserialize(json, Options)
+ ?? throw new InvalidOperationException("Bundle JSON deserialized to null.");
+ }
+
+ /// Projects a stored row into its ciphertext-only bundle entry.
+ /// The stored row to export.
+ /// The bundle entry mirroring .
+ public static BundleEntry FromRow(StoredSecret row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ return new BundleEntry(
+ Name: row.Name.Value,
+ Description: row.Description,
+ ContentType: row.ContentType.ToString(),
+ Ciphertext: Convert.ToBase64String(row.Ciphertext),
+ Nonce: Convert.ToBase64String(row.Nonce),
+ Tag: Convert.ToBase64String(row.Tag),
+ WrappedDek: Convert.ToBase64String(row.WrappedDek),
+ WrapNonce: Convert.ToBase64String(row.WrapNonce),
+ WrapTag: Convert.ToBase64String(row.WrapTag),
+ KekId: row.KekId,
+ Revision: row.Revision,
+ IsDeleted: row.IsDeleted,
+ DeletedUtc: row.DeletedUtc,
+ CreatedUtc: row.CreatedUtc,
+ UpdatedUtc: row.UpdatedUtc,
+ CreatedBy: row.CreatedBy,
+ UpdatedBy: row.UpdatedBy);
+ }
+
+ /// Rebuilds a stored row from a bundle entry.
+ /// The bundle entry to rehydrate.
+ /// The the entry was projected from.
+ public static StoredSecret ToRow(BundleEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ return new StoredSecret
+ {
+ Name = new SecretName(entry.Name),
+ Description = entry.Description,
+ ContentType = Enum.Parse(entry.ContentType),
+ Ciphertext = Convert.FromBase64String(entry.Ciphertext),
+ Nonce = Convert.FromBase64String(entry.Nonce),
+ Tag = Convert.FromBase64String(entry.Tag),
+ WrappedDek = Convert.FromBase64String(entry.WrappedDek),
+ WrapNonce = Convert.FromBase64String(entry.WrapNonce),
+ WrapTag = Convert.FromBase64String(entry.WrapTag),
+ KekId = entry.KekId,
+ Revision = entry.Revision,
+ IsDeleted = entry.IsDeleted,
+ DeletedUtc = entry.DeletedUtc,
+ CreatedUtc = entry.CreatedUtc,
+ UpdatedUtc = entry.UpdatedUtc,
+ CreatedBy = entry.CreatedBy,
+ UpdatedBy = entry.UpdatedBy,
+ };
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
new file mode 100644
index 0000000..ad36656
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs
@@ -0,0 +1,263 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against real temp-dir SQLite stores: the same-KEK
+/// export/import round-trip, the no-plaintext-at-rest guarantee, tombstone filtering,
+/// last-writer-wins conflict resolution (and the per-row override that can force a bundle row),
+/// and cross-KEK import (rewrap when the source KEK is supplied, skip-and-report when it is not).
+///
+public sealed class BundleServiceTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-bundle-{Guid.NewGuid():N}");
+
+ public BundleServiceTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string Db(string name) => Path.Combine(_dir, name);
+
+ private string BundlePath => Path.Combine(_dir, "bundle.json");
+
+ private static LiteralMasterKeyProvider NewKek()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+ }
+
+ private static SecretsSession BuildSession(string dbPath, LiteralMasterKeyProvider kek)
+ {
+ var factory = new SecretsSqliteConnectionFactory(dbPath);
+ var migrator = new SqliteSecretsStoreMigrator(factory);
+ migrator.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
+ return new SecretsSession
+ {
+ Target = new TargetConfigReader().Manual(dbPath, new MasterKeyOptions()),
+ Store = new SqliteSecretStore(factory),
+ Migrator = migrator,
+ MasterKey = kek,
+ Cipher = new AesGcmEnvelopeCipher(kek),
+ StoreKind = "sqlite",
+ };
+ }
+
+ private static async Task SealAsync(SecretsSession session, string name, string value)
+ {
+ StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ private void WriteBundle(string sourceKekId, params StoredSecret[] rows)
+ {
+ var bundle = new SecretBundle
+ {
+ ExportedUtc = DateTimeOffset.UtcNow,
+ SourceKekId = sourceKekId,
+ Entries = rows.Select(SecretBundleCodec.FromRow).ToList(),
+ };
+ File.WriteAllText(BundlePath, SecretBundleCodec.Serialize(bundle));
+ }
+
+ [Fact]
+ public async Task Export_then_import_into_empty_store_roundtrips_rows()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/one", "value-one");
+ await SealAsync(source, "svc/two", "value-two");
+
+ var service = new BundleService();
+ int exported = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+ Assert.Equal(2, exported);
+
+ // Same KEK, fresh empty store.
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(2, report.Imported);
+ Assert.Equal(0, report.SkippedForeignKek);
+
+ foreach ((string name, string value) in new[] { ("svc/one", "value-one"), ("svc/two", "value-two") })
+ {
+ StoredSecret? src = await source.Store.GetAsync(new SecretName(name), CancellationToken.None);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName(name), CancellationToken.None);
+ Assert.NotNull(src);
+ Assert.NotNull(dst);
+
+ // Fields UpsertAsync preserves verbatim.
+ Assert.Equal(src!.Name.Value, dst!.Name.Value);
+ Assert.Equal(src.ContentType, dst.ContentType);
+ Assert.Equal(src.Description, dst.Description);
+ Assert.Equal(src.KekId, dst.KekId);
+ Assert.Equal(src.Ciphertext, dst.Ciphertext);
+ Assert.Equal(src.Nonce, dst.Nonce);
+ Assert.Equal(src.Tag, dst.Tag);
+ Assert.Equal(src.WrappedDek, dst.WrappedDek);
+ Assert.Equal(src.WrapNonce, dst.WrapNonce);
+ Assert.Equal(src.WrapTag, dst.WrapTag);
+
+ // And it decrypts to the same plaintext under the same KEK.
+ Assert.Equal(value, target.Cipher!.Decrypt(dst));
+ }
+ }
+
+ [Fact]
+ public async Task Bundle_json_contains_no_plaintext()
+ {
+ const string sentinel = "SENTINEL-3f9a2c-DO-NOT-LEAK";
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/secret", sentinel);
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ string raw = await File.ReadAllTextAsync(BundlePath, CancellationToken.None);
+ Assert.DoesNotContain(sentinel, raw, StringComparison.Ordinal);
+
+ // The base64 ciphertext of the sealed row is present in the file.
+ StoredSecret? row = await source.Store.GetAsync(new SecretName("svc/secret"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.Contains(Convert.ToBase64String(row!.Ciphertext), raw, StringComparison.Ordinal);
+ Assert.Contains("\"Ciphertext\"", raw, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Export_excludes_tombstones_by_default_and_includes_them_when_asked()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kek);
+ await SealAsync(source, "svc/live", "live-value");
+ await SealAsync(source, "svc/dead", "dead-value");
+ await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: null, CancellationToken.None);
+
+ var service = new BundleService();
+
+ int excluded = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+ Assert.Equal(1, excluded);
+ SecretBundle withoutDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
+ Assert.Equal(["svc/live"], withoutDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());
+
+ int included = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
+ Assert.Equal(2, included);
+ SecretBundle withDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
+ Assert.Equal(["svc/dead", "svc/live"], withDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());
+ }
+
+ [Fact]
+ public async Task Import_conflict_resolved_by_last_writer_wins()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ await SealAsync(target, "svc/conf", "existing-value");
+ StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+
+ var service = new BundleService();
+
+ // Incoming OLDER than the existing row -> existing survives.
+ StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "older-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, older);
+ BundleImportReport olderReport =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(0, olderReport.Imported);
+ Assert.Equal(1, olderReport.SkippedOlder);
+ Assert.Equal(1, olderReport.Conflicts);
+ StoredSecret afterOlder = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("existing-value", target.Cipher!.Decrypt(afterOlder));
+
+ // Incoming NEWER than the existing row -> bundle row replaces it.
+ StoredSecret newer = target.Cipher!.Encrypt(new SecretName("svc/conf"), "newer-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, newer);
+ BundleImportReport newerReport =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+ Assert.Equal(1, newerReport.Imported);
+ Assert.Equal(0, newerReport.SkippedOlder);
+ Assert.Equal(1, newerReport.Conflicts);
+ StoredSecret afterNewer = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("newer-bundle-value", target.Cipher!.Decrypt(afterNewer));
+ }
+
+ [Fact]
+ public async Task Import_conflict_callback_can_force_bundle_row()
+ {
+ LiteralMasterKeyProvider kek = NewKek();
+ SecretsSession target = BuildSession(Db("b.db"), kek);
+ await SealAsync(target, "svc/conf", "existing-value");
+ StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+
+ // Incoming is OLDER, so LWW would skip it — but the override forces take-incoming.
+ StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "forced-bundle-value", SecretContentType.Text)
+ with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
+ WriteBundle(kek.KekId, older);
+
+ var service = new BundleService();
+ BundleImportReport report = await service.ImportAsync(
+ target, BundlePath, sourceKek: null, conflictOverride: (_, _) => true, CancellationToken.None);
+
+ Assert.Equal(1, report.Imported);
+ Assert.Equal(0, report.SkippedOlder);
+ Assert.Equal(1, report.Conflicts);
+ StoredSecret after = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
+ Assert.Equal("forced-bundle-value", target.Cipher!.Decrypt(after));
+ }
+
+ [Fact]
+ public async Task Cross_kek_import_rewraps_when_source_kek_supplied()
+ {
+ LiteralMasterKeyProvider kekA = NewKek();
+ LiteralMasterKeyProvider kekB = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ SecretsSession target = BuildSession(Db("b.db"), kekB);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None);
+
+ Assert.Equal(1, report.Imported);
+ Assert.Equal(0, report.SkippedForeignKek);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
+ Assert.NotNull(dst);
+ Assert.Equal(kekB.KekId, dst!.KekId);
+ Assert.Equal("cross-value", target.Cipher!.Decrypt(dst));
+ }
+
+ [Fact]
+ public async Task Cross_kek_import_without_source_kek_skips_rows_and_reports_them()
+ {
+ LiteralMasterKeyProvider kekA = NewKek();
+ LiteralMasterKeyProvider kekB = NewKek();
+ SecretsSession source = BuildSession(Db("a.db"), kekA);
+ await SealAsync(source, "svc/cross", "cross-value");
+
+ var service = new BundleService();
+ await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
+
+ SecretsSession target = BuildSession(Db("b.db"), kekB);
+ BundleImportReport report =
+ await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
+
+ Assert.Equal(0, report.Imported);
+ Assert.Equal(1, report.SkippedForeignKek);
+ StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
+ Assert.Null(dst);
+ }
+}