diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs
new file mode 100644
index 0000000..e2bcec8
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs
@@ -0,0 +1,55 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Soft-deletes (tombstones) a secret after an explicit confirmation. Tombstoning only rewrites metadata
+/// columns — it never touches ciphertext — so it needs no KEK and runs on a degraded session. A declined
+/// confirmation leaves the row untouched; a confirmed delete reports whether a matching row was found.
+///
+public sealed class DeleteSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Delete a secret";
+
+ ///
+ public bool RequiresKek => false;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(NameValidation)));
+
+ if (!console.Prompt(new ConfirmationPrompt($"Delete (tombstone) '{name.Value}'?") { DefaultValue = false }))
+ {
+ console.MarkupLine("[yellow]Left in place.[/]");
+ return;
+ }
+
+ string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
+ bool removed = await session.Store.DeleteAsync(name, actor, ct).ConfigureAwait(false);
+
+ console.MarkupLine(removed
+ ? $"[green]Tombstoned '{Markup.Escape(name.Value)}'.[/]"
+ : $"[red]No secret named '{Markup.Escape(name.Value)}'.[/]");
+ }
+
+ // Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
+ private static ValidationResult NameValidation(string raw)
+ {
+ try
+ {
+ _ = new SecretName(raw);
+ return ValidationResult.Success();
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationResult.Error(ex.Message);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs
new file mode 100644
index 0000000..fe52e18
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs
@@ -0,0 +1,56 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Lists the safe metadata projection of every live secret as a table — name, content type, KEK id,
+/// revision, last-updated stamp, and updater. It renders only (which carries
+/// no ciphertext or plaintext), needs no KEK, and never decrypts, so it is the one CRUD flow that runs on
+/// a degraded session.
+///
+public sealed class ListSecretsFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "List secrets";
+
+ ///
+ public bool RequiresKek => false;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ IReadOnlyList items =
+ await session.Store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
+
+ if (items.Count == 0)
+ {
+ console.MarkupLine("[grey](none)[/]");
+ return;
+ }
+
+ var table = new Table()
+ .AddColumn("Name")
+ .AddColumn("ContentType")
+ .AddColumn("KekId")
+ .AddColumn("Revision")
+ .AddColumn("Updated")
+ .AddColumn("UpdatedBy");
+
+ foreach (SecretMetadata m in items)
+ {
+ table.AddRow(
+ Markup.Escape(m.Name.Value),
+ Markup.Escape(m.ContentType.ToString()),
+ Markup.Escape(m.KekId),
+ m.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Markup.Escape(m.UpdatedUtc.ToString("u", System.Globalization.CultureInfo.InvariantCulture)),
+ Markup.Escape(m.UpdatedBy ?? "—"));
+ }
+
+ console.Write(table);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs
new file mode 100644
index 0000000..e36a4fd
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs
@@ -0,0 +1,57 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Reveals a secret's decrypted plaintext on screen — the one flow whose purpose is to surface a value.
+/// It gates the disclosure behind an explicit confirmation: on decline nothing is printed; on accept the
+/// value is decrypted with the session cipher and written exactly once (no caching, no resolver). Requires
+/// a KEK-capable session.
+///
+public sealed class RevealSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Get (reveal) a secret";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(NameValidation)));
+
+ StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (row is null || row.IsDeleted)
+ {
+ console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
+ return;
+ }
+
+ if (!console.Prompt(new ConfirmationPrompt("Reveal the value on screen?") { DefaultValue = false }))
+ {
+ return;
+ }
+
+ console.WriteLine(session.Cipher!.Decrypt(row));
+ }
+
+ // Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
+ private static ValidationResult NameValidation(string raw)
+ {
+ try
+ {
+ _ = new SecretName(raw);
+ return ValidationResult.Success();
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationResult.Error(ex.Message);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs
new file mode 100644
index 0000000..836ec07
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs
@@ -0,0 +1,68 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// Creates or rotates a secret: prompts for a validated name, confirms before overwriting an existing
+/// live row, reads the value through a masked prompt, and seals it under a fresh per-secret DEK via the
+/// session cipher. The value is never echoed and the confirmation line carries the name only. Requires a
+/// KEK-capable session (the shell upgrades a degraded session before dispatch).
+///
+public sealed class SetSecretFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Set / rotate a secret";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var name = new SecretName(console.Prompt(
+ new TextPrompt("Secret [green]name[/]:").Validate(NameValidation)));
+
+ StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
+ if (existing is { IsDeleted: false } &&
+ !console.Prompt(new ConfirmationPrompt($"'{name.Value}' exists — overwrite in place?") { DefaultValue = false }))
+ {
+ console.MarkupLine("[yellow]Left unchanged.[/]");
+ return;
+ }
+
+ string value = console.Prompt(new TextPrompt("Secret [green]value[/]:").Secret());
+ SecretContentType contentType = console.Prompt(
+ new SelectionPrompt().Title("Content type").AddChoices(Enum.GetValues()));
+ string description = console.Prompt(
+ new TextPrompt("Description [grey](optional)[/]:").AllowEmpty());
+
+ string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
+ StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with
+ {
+ Description = string.IsNullOrWhiteSpace(description) ? null : description,
+ CreatedBy = actor,
+ UpdatedBy = actor,
+ };
+ await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
+
+ console.MarkupLineInterpolated($"[green]Sealed '{name.Value}'.[/]");
+ }
+
+ // Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
+ private static ValidationResult NameValidation(string raw)
+ {
+ try
+ {
+ _ = new SecretName(raw);
+ return ValidationResult.Success();
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationResult.Error(ex.Message);
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs
new file mode 100644
index 0000000..3a69732
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs
@@ -0,0 +1,220 @@
+using System.Security.Cryptography;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
+
+///
+/// Drives the four CRUD flows (, ,
+/// , ) directly against a real temp-SQLite
+/// full session (KEK supplied as a override). Each flow renders to
+/// a scripted , so the tests assert on rendered
+/// and on the resulting store state — the invariant under test is that value plaintext only ever reaches
+/// the screen through the reveal flow's explicit confirmation.
+///
+public sealed class CrudFlowTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-crud-{Guid.NewGuid():N}");
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public CrudFlowTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private static TestConsole NewConsole()
+ {
+ var console = (TestConsole)new TestConsole().Interactive();
+ console.Profile.Width = 240; // wide enough that the metadata table never truncates a cell
+ return console;
+ }
+
+ // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
+ // test never depends on ambient environment state.
+ private async Task NewFullSessionAsync()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
+
+ StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
+ });
+
+ return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
+ }
+
+ private static async Task SeedAsync(
+ SecretsSession session, string name, string value, SecretContentType contentType = SecretContentType.Text)
+ {
+ var secretName = new SecretName(name);
+ StoredSecret row = session.Cipher!.Encrypt(secretName, value, contentType);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ int count = 0;
+ for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
+ i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ // --- List ---
+
+ [Fact]
+ public async Task Renders_metadata_table_never_values()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "db/conn", "SENTINEL-CONN-VALUE", SecretContentType.ConnectionString);
+ await SeedAsync(session, "api/key", "SENTINEL-API-VALUE");
+
+ TestConsole console = NewConsole();
+ await new ListSecretsFlow().RunAsync(console, session, CancellationToken.None);
+
+ string output = console.Output;
+ Assert.Contains("db/conn", output, StringComparison.Ordinal);
+ Assert.Contains("api/key", output, StringComparison.Ordinal);
+ Assert.Contains("ConnectionString", output, StringComparison.Ordinal);
+ Assert.Contains(session.MasterKey!.KekId, output, StringComparison.Ordinal); // kek id column
+
+ Assert.DoesNotContain("SENTINEL-CONN-VALUE", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("SENTINEL-API-VALUE", output, StringComparison.Ordinal);
+
+ Assert.False(new ListSecretsFlow().RequiresKek);
+ }
+
+ // --- Set / rotate ---
+
+ [Fact]
+ public async Task Masked_prompt_seals_and_upserts()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("app/token"); // name
+ console.Input.PushTextWithEnter("TOPSECRET-PLAINTEXT"); // value (masked)
+ console.Input.PushKey(ConsoleKey.Enter); // content-type: first choice (Text)
+ console.Input.PushKey(ConsoleKey.Enter); // description: empty
+
+ await new SetSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("app/token"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.Equal("TOPSECRET-PLAINTEXT", session.Cipher!.Decrypt(row!));
+ Assert.DoesNotContain("TOPSECRET-PLAINTEXT", console.Output, StringComparison.Ordinal); // never echoed
+ }
+
+ [Fact]
+ public async Task Existing_name_confirms_overwrite()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "shared/api", "OLD-VALUE");
+
+ // Decline: the value must be left unchanged.
+ TestConsole declineConsole = NewConsole();
+ declineConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
+ declineConsole.Input.PushTextWithEnter("n"); // overwrite? no
+ await new SetSecretFlow().RunAsync(declineConsole, session, CancellationToken.None);
+
+ StoredSecret? afterDecline = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
+ Assert.Equal("OLD-VALUE", session.Cipher!.Decrypt(afterDecline!));
+
+ // Accept: the value is overwritten in place.
+ TestConsole acceptConsole = NewConsole();
+ acceptConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
+ acceptConsole.Input.PushTextWithEnter("y"); // overwrite? yes
+ acceptConsole.Input.PushTextWithEnter("NEW-VALUE"); // value (masked)
+ acceptConsole.Input.PushKey(ConsoleKey.Enter); // content-type: Text
+ acceptConsole.Input.PushKey(ConsoleKey.Enter); // description: empty
+ await new SetSecretFlow().RunAsync(acceptConsole, session, CancellationToken.None);
+
+ StoredSecret? afterAccept = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
+ Assert.Equal("NEW-VALUE", session.Cipher!.Decrypt(afterAccept!));
+ }
+
+ // --- Reveal ---
+
+ [Fact]
+ public async Task Confirm_then_prints_value_once()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("reveal/me"); // name
+ console.Input.PushTextWithEnter("y"); // reveal on screen? yes
+
+ await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Equal(1, CountOccurrences(console.Output, "REVEAL-SENTINEL-XYZ"));
+ }
+
+ [Fact]
+ public async Task Decline_prints_nothing()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("reveal/me"); // name
+ console.Input.PushTextWithEnter("n"); // reveal on screen? no
+
+ await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.DoesNotContain("REVEAL-SENTINEL-XYZ", console.Output, StringComparison.Ordinal);
+ }
+
+ // --- Delete ---
+
+ [Fact]
+ public async Task Confirm_tombstones_row()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "del/me", "GONE");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("del/me"); // name
+ console.Input.PushTextWithEnter("y"); // delete? yes
+
+ await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.True(row!.IsDeleted);
+ }
+
+ [Fact]
+ public async Task Decline_leaves_row()
+ {
+ SecretsSession session = await NewFullSessionAsync();
+ await SeedAsync(session, "del/me", "STAYS");
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("del/me"); // name
+ console.Input.PushTextWithEnter("n"); // delete? no
+
+ await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
+ Assert.NotNull(row);
+ Assert.False(row!.IsDeleted);
+ }
+}