fix(host): publish the known-id set and its stamp atomically; never write the shared guard options

BoundedApiKeyStore kept the set (volatile) and its DateTimeOffset stamp (non-volatile, 16 bytes) in separate
fields, so a reader could pair a new set with an old stamp or read a torn stamp; they are now one immutable
snapshot behind a volatile reference. An inner store that cannot list ids now switches the set off with a
per-instance flag instead of writing KnownIdSet = false into the shared ApiKeyStoreGuardOptions.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:51:28 -04:00
parent 896aaa2b02
commit dc3323fcca
2 changed files with 53 additions and 12 deletions
@@ -120,6 +120,36 @@ public sealed class BoundedApiKeyStoreKnownIdTests
inner.ListCalls.ShouldBe(0);
}
/// <summary>
/// Fix round 1 minor — an inner store that cannot list ids switches the known-id set off for THIS
/// decorator only. It used to do so by writing <c>KnownIdSet = false</c> into the guard options it was
/// handed, a shared options object, so one store's capability leaked into every other consumer.
/// Positive control: a second decorator over a listing store on the same options instance still uses
/// the set.
/// </summary>
[Fact]
public async Task An_unlisting_store_does_not_mutate_the_shared_guard_options()
{
var guard = new ApiKeyStoreGuardOptions { MissBurst = 100, MissRefillPerSecond = 20 };
var options = Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 5,
});
var clock = new FakeClock(T0);
var unlisting = new ListingStore("k-real") { SupportsListing = false };
var first = new BoundedApiKeyStore(unlisting, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await first.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
unlisting.FindCalls.ShouldBe(1, "no set: the per-id path ran");
guard.KnownIdSet.ShouldBeTrue("the shared options object is never written");
var listing = new ListingStore("k-real");
var second = new BoundedApiKeyStore(listing, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await second.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
listing.FindCalls.ShouldBe(0, "the second decorator still refuses unknown ids in memory");
listing.ListCalls.ShouldBe(1);
}
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, TimeProvider clock, int cacheSeconds, int missBurst = 100, double refillPerSecond = 20) => new(
inner,