diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs
new file mode 100644
index 0000000..b26d0ab
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs
@@ -0,0 +1,142 @@
+using System.Globalization;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+///
+/// The deployment-setup flow: audits every ${secret:NAME} reference in the target app's composed
+/// configuration, renders a colour-coded status table, then walks the operator through seeding each gap so
+/// the app can start. For every reference that is not already resolvable — and that can be seeded
+/// (, , or
+/// ) — the operator is offered a masked value prompt and the
+/// new secret is sealed under a fresh per-secret DEK via the session cipher. Seeding a
+/// reference revives it: the store's
+/// overwrites the row in place and clears its tombstone. Seeding an
+/// reference overwrites the unreadable row with a fresh value
+/// (the confirm text says so). An reference cannot be looked up
+/// or seeded, so it renders a fix-the-token hint instead of a prompt;
+/// only occurs on a degraded session and is unreachable
+/// here (the flow requires a KEK). A closing re-audit prints a one-line count-by-status summary. Requires a
+/// KEK-capable session (the shell upgrades a degraded session before dispatch); typed values are masked and
+/// never echoed.
+///
+public sealed class ReferenceAuditFlow : IInteractiveFlow
+{
+ ///
+ public string Title => "Reference audit & seed";
+
+ ///
+ public bool RequiresKek => true;
+
+ ///
+ public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var auditor = new ReferenceAuditor();
+ IReadOnlyList findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false);
+ if (findings.Count == 0)
+ {
+ console.MarkupLine("[grey]No ${secret:} references found in the target configuration.[/]");
+ return;
+ }
+
+ RenderTable(console, findings);
+
+ foreach (ReferenceFinding finding in findings)
+ {
+ if (finding.Status == ReferenceStatus.InvalidName)
+ {
+ console.MarkupLineInterpolated(
+ $"[red]'{finding.SecretName}'[/] is not a valid secret name — fix the ${{secret:…}} token in the config; it cannot be seeded.");
+ continue;
+ }
+
+ if (!IsSeedable(finding.Status))
+ {
+ continue; // Ok (nothing to do) or PresentUnverified (unreachable on a KEK-capable session).
+ }
+
+ string escapedName = Markup.Escape(finding.SecretName);
+ string question = finding.Status == ReferenceStatus.Undecryptable
+ ? $"'{escapedName}' exists but does not decrypt — overwrite with a fresh value now?"
+ : $"Seed '{escapedName}' now?";
+
+ if (!console.Prompt(new ConfirmationPrompt(question)
+ {
+ DefaultValue = finding.Status != ReferenceStatus.Undecryptable,
+ }))
+ {
+ continue;
+ }
+
+ await SeedAsync(console, session, new SecretName(finding.SecretName), ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]");
+ }
+
+ IReadOnlyList after = await auditor.AuditAsync(session, ct).ConfigureAwait(false);
+ console.MarkupLineInterpolated($"Audit complete: {Summarize(after)}.");
+ }
+
+ // Seedable ⇔ a valid, non-resolvable reference we can fill: absent, tombstoned, or present-but-unreadable.
+ // Ok needs nothing; InvalidName cannot be looked up; PresentUnverified only occurs on a degraded session.
+ private static bool IsSeedable(ReferenceStatus status) =>
+ status is ReferenceStatus.Missing or ReferenceStatus.Tombstoned or ReferenceStatus.Undecryptable;
+
+ // Prompts for a masked value + content type + optional description and seals the secret under a fresh
+ // per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it.
+ private static async Task SeedAsync(
+ IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct)
+ {
+ string value = console.Prompt(
+ new TextPrompt($" Value for [green]{Markup.Escape(name.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); // revives a tombstone: overwrite clears IsDeleted.
+ }
+
+ // A colour-coded audit table: green Ok, yellow PresentUnverified, red for every unresolvable status.
+ private static void RenderTable(IAnsiConsole console, IReadOnlyList findings)
+ {
+ var table = new Table()
+ .AddColumn("Secret")
+ .AddColumn("Status")
+ .AddColumn("Config paths");
+
+ foreach (ReferenceFinding finding in findings)
+ {
+ table.AddRow(
+ Markup.Escape(finding.SecretName),
+ $"[{StatusColor(finding.Status)}]{finding.Status}[/]",
+ Markup.Escape(string.Join(Environment.NewLine, finding.ConfigPaths)));
+ }
+
+ console.Write(table);
+ }
+
+ private static string StatusColor(ReferenceStatus status) => status switch
+ {
+ ReferenceStatus.Ok => "green",
+ ReferenceStatus.PresentUnverified => "yellow",
+ _ => "red", // Missing / Tombstoned / Undecryptable / InvalidName
+ };
+
+ // A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
+ private static string Summarize(IReadOnlyList findings) =>
+ string.Join(", ", findings
+ .GroupBy(f => f.Status)
+ .OrderBy(g => g.Key)
+ .Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs
new file mode 100644
index 0000000..2217abc
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs
@@ -0,0 +1,195 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Configuration;
+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 deployment-setup against a real temp-SQLite full session
+/// whose references three ${secret:NAME} tokens — one
+/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted
+/// , so the tests assert both the rendered and
+/// the resulting store state. The invariant under test is that the operator can fill every startup gap
+/// from one screen without any typed secret value ever reaching the terminal.
+///
+public sealed class ReferenceAuditFlowTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-refaudit-{Guid.NewGuid():N}");
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ public ReferenceAuditFlowTests() => 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 status table never truncates a cell
+ return console;
+ }
+
+ // A full (KEK-capable) session on a fresh temp SQLite store, with an optional in-memory configuration
+ // layered onto the target so the reference auditor sees ${secret:...} tokens. Keyed by an operator-
+ // override KEK so the test never depends on ambient environment state.
+ private async Task NewSessionAsync(
+ IEnumerable>? configEntries = null)
+ {
+ 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}",
+ });
+
+ if (configEntries is not null)
+ {
+ IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(configEntries).Build();
+ target = target with { Configuration = config };
+ }
+
+ return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
+ }
+
+ // The canonical three-reference configuration: svc/ok (seeded), svc/missing (absent), svc/tomb
+ // (tombstoned). Ordinal name order is svc/missing < svc/ok < svc/tomb, so the non-Ok findings the
+ // flow prompts for arrive in the order missing, then tomb.
+ private static IEnumerable> ThreeReferenceConfig() =>
+ [
+ new("ConnectionStrings:Ok", "${secret:svc/ok}"),
+ new("Api:MissingKey", "${secret:svc/missing}"),
+ new("Feature:TombValue", "${secret:svc/tomb}"),
+ ];
+
+ private static async Task SeedAsync(SecretsSession session, string name, string value)
+ {
+ var secretName = new SecretName(name);
+ StoredSecret row = session.Cipher!.Encrypt(secretName, value, SecretContentType.Text);
+ await session.Store.UpsertAsync(row, CancellationToken.None);
+ }
+
+ // Seeds svc/ok live and leaves svc/missing absent, then seeds + tombstones svc/tomb.
+ private static async Task SeedThreeReferenceStateAsync(SecretsSession session)
+ {
+ await SeedAsync(session, "svc/ok", "OK-SEEDED-VALUE");
+ await SeedAsync(session, "svc/tomb", "TOMB-ORIGINAL-VALUE");
+ await session.Store.DeleteAsync(new SecretName("svc/tomb"), "test", 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;
+ }
+
+ [Fact]
+ public async Task Renders_status_table_for_all_references()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("n"); // svc/missing: decline (this test only asserts the rendered table)
+ console.Input.PushTextWithEnter("n"); // svc/tomb: decline
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ string output = console.Output;
+ Assert.Contains("svc/ok", output, StringComparison.Ordinal);
+ Assert.Contains("svc/missing", output, StringComparison.Ordinal);
+ Assert.Contains("svc/tomb", output, StringComparison.Ordinal);
+ Assert.Contains("Missing", output, StringComparison.Ordinal);
+ Assert.Contains("Tombstoned", output, StringComparison.Ordinal);
+ Assert.Contains("ConnectionStrings:Ok", output, StringComparison.Ordinal);
+ Assert.Contains("Api:MissingKey", output, StringComparison.Ordinal);
+ Assert.Contains("Feature:TombValue", output, StringComparison.Ordinal);
+
+ Assert.True(new ReferenceAuditFlow().RequiresKek);
+ }
+
+ [Fact]
+ public async Task Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ // svc/missing (first non-Ok): accept, value, Text (Enter), empty description.
+ console.Input.PushTextWithEnter("y");
+ console.Input.PushTextWithEnter("MISSING-SEED-SENTINEL");
+ console.Input.PushKey(ConsoleKey.Enter);
+ console.Input.PushKey(ConsoleKey.Enter);
+ // svc/tomb (second non-Ok): accept, value, Text (Enter), empty description.
+ console.Input.PushTextWithEnter("y");
+ console.Input.PushTextWithEnter("TOMB-SEED-SENTINEL");
+ console.Input.PushKey(ConsoleKey.Enter);
+ console.Input.PushKey(ConsoleKey.Enter);
+
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ IReadOnlyList after =
+ await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
+ Assert.All(after, f => Assert.Equal(ReferenceStatus.Ok, f.Status));
+
+ // The store now decrypts both freshly-seeded values.
+ StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
+ StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
+ Assert.Equal("MISSING-SEED-SENTINEL", session.Cipher!.Decrypt(missing!));
+ Assert.Equal("TOMB-SEED-SENTINEL", session.Cipher!.Decrypt(tomb!));
+ Assert.False(tomb!.IsDeleted); // upsert revived the tombstone
+
+ Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
+ Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Skipped_references_are_left_untouched()
+ {
+ SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
+ await SeedThreeReferenceStateAsync(session);
+
+ TestConsole console = NewConsole();
+ console.Input.PushTextWithEnter("n"); // svc/missing: decline
+ console.Input.PushTextWithEnter("n"); // svc/tomb: decline
+
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
+ StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
+ Assert.Null(missing);
+ Assert.NotNull(tomb);
+ Assert.True(tomb!.IsDeleted);
+ }
+
+ [Fact]
+ public async Task Manual_target_without_appsettings_reports_nothing_to_audit()
+ {
+ SecretsSession session = await NewSessionAsync(); // Manual target: empty composed configuration
+
+ TestConsole console = NewConsole();
+ await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);
+
+ Assert.Contains("No ${secret:} references found", console.Output, StringComparison.Ordinal);
+ Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt was rendered
+ }
+}