diff --git a/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs b/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs
index 9febc073..3b386b7b 100644
--- a/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs
+++ b/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs
@@ -1,16 +1,23 @@
-using System.Text.Json;
-using Npgsql;
+using GenWave.MediaLibrary.Station;
namespace GenWave.Host.Seeding;
///
-/// backed directly by station.settings on the Station
-/// connection — the same table
+/// backed by against
+/// the same station.settings table
/// writes to, but reached through a separate, narrower seam so the marker key can never be
/// allowlisted by accident (F27.10).
+///
+/// gh-#406 slice 4: the raw station.settings row I/O this class used to open directly via
+/// NpgsqlConnection now lives in — this class builds
+/// that repository internally from the same it always took (no DI
+/// wiring change; SafeLoopSeedServiceCollectionExtensions still constructs this type exactly
+/// as before) and keeps only the marker-key scoping that is genuinely this store's own concern.
///
public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoopSeedMarkerStore
{
+ readonly StationSettingsRepository repository = new(connectionString);
+
///
/// The marker key. Lives outside the Station:* config namespace (so it can never collide
/// with a bound options section) and is absent from
@@ -20,40 +27,15 @@ public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoop
public const string Key = "Internal:BootSeed:SafeLoopCompletedAt";
///
- public async Task ExistsAsync(CancellationToken ct)
- {
- await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
-
- await using var cmd = conn.CreateCommand();
- cmd.CommandText = "SELECT 1 FROM station.settings WHERE key = @key";
- cmd.Parameters.AddWithValue("key", Key);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- return await reader.ReadAsync(ct);
- }
+ public Task ExistsAsync(CancellationToken ct) => repository.ExistsAsync(Key, ct);
///
- public async Task MarkCompletedAsync(CancellationToken ct)
+ public Task MarkCompletedAsync(CancellationToken ct)
{
// The value carries a UTC timestamp for operator diagnosability (visible only via a direct
- // psql query — never through the settings API); its content is otherwise unused.
- var json = JsonSerializer.Serialize(DateTimeOffset.UtcNow);
-
- await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
-
- await using var cmd = conn.CreateCommand();
- cmd.CommandText =
- """
- INSERT INTO station.settings (key, value, updated_at)
- VALUES (@key, @value::jsonb, now())
- ON CONFLICT (key) DO UPDATE
- SET value = EXCLUDED.value,
- updated_at = EXCLUDED.updated_at
- """;
- cmd.Parameters.AddWithValue("key", Key);
- cmd.Parameters.AddWithValue("value", json);
- await cmd.ExecuteNonQueryAsync(ct);
+ // psql query — never through the settings API); its content is otherwise unused. This is the
+ // same upsert WriteAsync already performs for every other settings key — no new repository
+ // SQL needed for the write side (gh-#406 slice 4 only added ExistsAsync).
+ return repository.WriteAsync(Key, DateTimeOffset.UtcNow, ct);
}
}
diff --git a/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs b/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs
index 58cfa66f..0daf6561 100644
--- a/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs
+++ b/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs
@@ -81,4 +81,25 @@ public async Task> ReadAllAsync(Cancellation
return result;
}
+
+ ///
+ /// True if a row for exists in station.settings — added for gh-#406
+ /// slice 4: GenWave.Host.Seeding.SafeLoopSeedMarkerStore's one-shot boot-seed marker check
+ /// (F27.10) needs a single-key existence probe, not the full unfiltered
+ /// scan. Any failure (including a ) propagates to the caller —
+ /// same posture as , degrade policy is a caller concern, not this
+ /// repository's.
+ ///
+ public async Task ExistsAsync(string key, CancellationToken ct)
+ {
+ await using var conn = new NpgsqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT 1 FROM station.settings WHERE key = @key";
+ cmd.Parameters.AddWithValue("key", key);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ return await reader.ReadAsync(ct);
+ }
}
diff --git a/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs b/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs
index 74a2a85f..cae99b65 100644
--- a/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs
+++ b/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs
@@ -40,11 +40,5 @@ internal static class ExemptionBaseline
"IConfigurationProvider.Load() queries station.settings directly via NpgsqlConnection at " +
"boot, before the DI container (and any MediaLibrary repository) exists to inject. " +
"Pre-existing (STORY-042); not trivial to fix in this diff — follow-up gh-#406."),
- new ArchitectureExemption(
- LawId.L2,
- "GenWave.Host.Seeding.SafeLoopSeedMarkerStore",
- "2026-08-07",
- "Reads/writes the boot-seed marker directly via NpgsqlConnection on the station.settings " +
- "table (F27.10). Pre-existing; not trivial to fix in this diff — follow-up gh-#406."),
};
}
diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs
index 85bcc2ab..c6d5a09a 100644
--- a/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs
+++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs
@@ -8,6 +8,10 @@
// allowlist-filtered/degrade-on-DB-down behavior stays GenWave.Host.Tests' own coverage
// (Story042_StationSettingsOverlayProvider.cs, FeatureStationSettingsOverlayProvider) — this
// repository deliberately returns every row unfiltered and lets failures propagate.
+//
+// gh-#406 slice 4 added ExistsAsync (single-key existence probe) for
+// GenWave.Host.Seeding.SafeLoopSeedMarkerStore's boot-seed marker check (F27.10) — its own
+// coverage is the FeatureExistsAsync section below.
using System.Text.Json;
using Dapper;
@@ -246,4 +250,57 @@ await Assert.ThrowsAsync(() => conn.ExecuteAsync(
"insert into station.settings (key, value) values (null, '\"x\"'::jsonb)"));
}
}
+
+ // ---------------------------------------------------------------------
+ // HAPPY PATH — ExistsAsync (gh-#406 slice 4)
+ // ---------------------------------------------------------------------
+
+ [Collection(DatabaseCollection.Name)]
+ [Trait("Category", "Integration")]
+ public sealed class ScenarioExists(DatabaseFixture db)
+ {
+ [Fact]
+ public async Task AMissingKeyReportsFalse()
+ {
+ // Given no row for this key...
+ await db.ResetSettingsAsync();
+ var repo = Repo(db);
+
+ // When its existence is probed...
+ var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None);
+
+ // Then it reports absent.
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public async Task AWrittenKeyReportsTrue()
+ {
+ // Given a row written under this key...
+ await db.ResetSettingsAsync();
+ var repo = Repo(db);
+ await repo.WriteAsync("Internal:BootSeed:SafeLoopCompletedAt", DateTimeOffset.UtcNow, CancellationToken.None);
+
+ // When its existence is probed...
+ var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None);
+
+ // Then it reports present.
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task ItOnlyReportsTheExactKeyProbedNotOtherRows()
+ {
+ // Given a row written under a different key...
+ await db.ResetSettingsAsync();
+ var repo = Repo(db);
+ await repo.WriteAsync("Loudness:TargetLufs", -14.0, CancellationToken.None);
+
+ // When a different key's existence is probed...
+ var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None);
+
+ // Then it reports absent.
+ Assert.False(exists);
+ }
+ }
}