diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs
new file mode 100644
index 0000000..2df8cd1
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs
@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never
+/// key material or secret values) so the interactive console can offer quick re-selection.
+///
+public sealed class RecentTargetsStore
+{
+ private const int MaxEntries = 10;
+
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ };
+
+ private readonly string _filePath;
+ private readonly TimeProvider _timeProvider;
+
+ /// Creates the store bound to a JSON file path, using an optional injected clock.
+ /// The JSON file to load from and save to.
+ ///
+ /// Clock used to stamp . Defaults to
+ /// when omitted.
+ ///
+ public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
+ {
+ _filePath = filePath;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ /// One remembered target: a display name, its appsettings.json path, and when it was last used.
+ /// Display name for the target (e.g. the app name).
+ /// Filesystem path to the target's appsettings.json.
+ /// Timestamp of the most recent selection of this target.
+ public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
+
+ /// Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.
+ public IReadOnlyList Load()
+ {
+ if (!File.Exists(_filePath))
+ {
+ return [];
+ }
+
+ try
+ {
+ var json = File.ReadAllText(_filePath);
+ var entries = JsonSerializer.Deserialize>(json, SerializerOptions);
+ return entries ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ catch (IOException)
+ {
+ return [];
+ }
+ }
+
+ /// Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.
+ /// Display name for the target.
+ /// Filesystem path to the target's appsettings.json — the dedup key.
+ public void Touch(string name, string appSettingsPath)
+ {
+ var existing = Load();
+ var remaining = existing.Where(e => !string.Equals(e.AppSettingsPath, appSettingsPath, StringComparison.Ordinal));
+ var touched = new RecentTarget(name, appSettingsPath, _timeProvider.GetUtcNow());
+ var updated = new List { touched };
+ updated.AddRange(remaining.Take(MaxEntries - 1));
+
+ Save(updated);
+ }
+
+ private void Save(IReadOnlyList entries)
+ {
+ var directory = Path.GetDirectoryName(_filePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var json = JsonSerializer.Serialize(entries, SerializerOptions);
+ var tmpPath = _filePath + ".tmp";
+ File.WriteAllText(tmpPath, json);
+ File.Move(tmpPath, _filePath, overwrite: true);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs
new file mode 100644
index 0000000..8da0722
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs
@@ -0,0 +1,127 @@
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Exercises against a real temp file, verifying round-trip
+/// persistence, ordering/dedup/cap behavior, corrupt-file tolerance, and — the hard security
+/// invariant — that the persisted JSON never carries anything beyond name/path/timestamp.
+///
+public sealed class RecentTargetsStoreTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-recent-{Guid.NewGuid():N}");
+
+ private string FilePath => Path.Combine(_dir, "recent-targets.json");
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ /// A minimal, manually-advanceable fake for deterministic ordering assertions.
+ private sealed class FakeTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+
+ public FakeTimeProvider(DateTimeOffset start) => _now = start;
+
+ public void Advance(TimeSpan by) => _now += by;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+ }
+
+ [Fact]
+ public void Load_returns_empty_when_file_missing()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ var result = sut.Load();
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Touch_then_load_roundtrips_name_and_path()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ var result = sut.Load();
+
+ var entry = Assert.Single(result);
+ Assert.Equal("OtOpcUa", entry.Name);
+ Assert.Equal("/apps/otopcua/appsettings.json", entry.AppSettingsPath);
+ }
+
+ [Fact]
+ public void Touch_existing_path_moves_it_first_and_updates_stamp()
+ {
+ var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ var sut = new RecentTargetsStore(FilePath, time);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ time.Advance(TimeSpan.FromMinutes(1));
+ sut.Touch("ScadaBridge", "/apps/scadabridge/appsettings.json");
+ time.Advance(TimeSpan.FromMinutes(1));
+ var beforeRetouch = time.GetUtcNow();
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+
+ var result = sut.Load();
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("OtOpcUa", result[0].Name);
+ Assert.Equal("/apps/otopcua/appsettings.json", result[0].AppSettingsPath);
+ Assert.Equal(beforeRetouch, result[0].LastUsedUtc);
+ Assert.Equal("ScadaBridge", result[1].Name);
+ }
+
+ [Fact]
+ public void Load_tolerates_corrupt_json_by_returning_empty()
+ {
+ Directory.CreateDirectory(_dir);
+ File.WriteAllText(FilePath, "{ this is not valid json ][");
+ var sut = new RecentTargetsStore(FilePath);
+
+ var result = sut.Load();
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Touch_caps_list_at_ten_entries()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ for (var i = 0; i < 12; i++)
+ {
+ sut.Touch($"App{i}", $"/apps/app{i}/appsettings.json");
+ }
+
+ var result = sut.Load();
+
+ Assert.Equal(10, result.Count);
+ Assert.Equal("App11", result[0].Name);
+ Assert.Equal("App2", result[9].Name);
+ }
+
+ [Fact]
+ public void Saved_json_contains_no_key_material_fields()
+ {
+ var sut = new RecentTargetsStore(FilePath);
+
+ sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
+ var json = File.ReadAllText(FilePath);
+
+ Assert.Contains("name", json, StringComparison.Ordinal);
+ Assert.Contains("appSettingsPath", json, StringComparison.Ordinal);
+ Assert.Contains("lastUsedUtc", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("key", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("kek", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("secret", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
+ }
+}