User Story
As a developer who uses IIdempotencyKeyRepository from NetEvolve.Pulse.SQLite directly, I want the time-to-live check to compare points in time, so that a key is treated as expired or valid correctly no matter which UTC offset my timestamps use.
Problem
SQLiteIdempotencyKeyRepository saves CreatedAt and binds validFrom as ISO 8601 strings, keeping whatever offset the caller passed. It then compares the two as TEXT:
src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:80: AND "CreatedAt" >= @validFrom (string comparison)
src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:111: AddWithValue("@validFrom", validFrom.Value.ToString("O"))
src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:136: AddWithValue("@createdAt", createdAt.ToString("O"))
The "O" format keeps the offset, for example 2026-09-27T10:00:00.0000000+02:00. A string comparison only matches the order in time when both values have the same offset.
Failure scenario:
StoreAsync("k", 2026-09-27T10:00:00+02:00) stores the key. That is 08:00Z.
ExistsAsync("k", validFrom: 2026-09-27T09:00:00+00:00) runs.
- As text,
"...T10:00...+02:00" >= "...T09:00...+00:00" is true, so the method returns true. The key was created before the cutoff, so it should return false.
The same thing happens the other way round: when the offsets are reversed, a valid key is reported as expired.
The other providers compare instants. NetEvolve.Pulse.MySql compares UtcTicks (MySqlIdempotencyKeyRepository.cs:111,136). SqlServer and PostgreSQL bind typed DateTimeOffset parameters. Redis and EF Core compare DateTimeOffset values. The SQLite outbox in the same package already converts to UTC with ToUniversalTime() (src/NetEvolve.Pulse.SQLite/Outbox/SQLiteOutboxRepository.cs:343, :520, :734).
Impact is low and does not show up in normal use. The built-in IdempotencyStore passes TimeProvider.GetUtcNow() for both values (src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:52, :63), so they are always +00:00. Only code that resolves the public IIdempotencyKeyRepository and passes timestamps with a non-zero offset is affected, or a custom TimeProvider that breaks the GetUtcNow contract.
Specification
Project contract, IIdempotencyKeyRepository.ExistsAsync (src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyKeyRepository.cs:28-33):
When set, only keys created at or after this timestamp are considered as existing. Keys older than this cutoff are treated as absent.
validFrom is a DateTimeOffset, so "at or after" means comparing points in time. DateTimeOffset comparison uses UtcDateTime (DateTimeOffset.CompareTo): "compares two DateTimeOffset objects based on their UTC date and time values". The round-trip format "O" keeps the original offset (Standard date and time format strings: the round-trip ("O", "o") format specifier), so the strings only sort in time order when every value has the same offset.
Requirements
- Convert both values to UTC before formatting:
createdAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) (line 136) and validFrom.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) (line 111). "O" is already culture-invariant, so CultureInfo.InvariantCulture is only there for the analyzers.
- Keep the column type and schema. No data migration is needed, because every row the built-in
IdempotencyStore writes already has +00:00.
Acceptance Criteria
User Story
As a developer who uses
IIdempotencyKeyRepositoryfromNetEvolve.Pulse.SQLitedirectly, I want the time-to-live check to compare points in time, so that a key is treated as expired or valid correctly no matter which UTC offset my timestamps use.Problem
SQLiteIdempotencyKeyRepositorysavesCreatedAtand bindsvalidFromas ISO 8601 strings, keeping whatever offset the caller passed. It then compares the two asTEXT:src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:80:AND "CreatedAt" >= @validFrom(string comparison)src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:111:AddWithValue("@validFrom", validFrom.Value.ToString("O"))src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs:136:AddWithValue("@createdAt", createdAt.ToString("O"))The
"O"format keeps the offset, for example2026-09-27T10:00:00.0000000+02:00. A string comparison only matches the order in time when both values have the same offset.Failure scenario:
StoreAsync("k", 2026-09-27T10:00:00+02:00)stores the key. That is 08:00Z.ExistsAsync("k", validFrom: 2026-09-27T09:00:00+00:00)runs."...T10:00...+02:00" >= "...T09:00...+00:00"is true, so the method returnstrue. The key was created before the cutoff, so it should returnfalse.The same thing happens the other way round: when the offsets are reversed, a valid key is reported as expired.
The other providers compare instants.
NetEvolve.Pulse.MySqlcomparesUtcTicks(MySqlIdempotencyKeyRepository.cs:111,136). SqlServer and PostgreSQL bind typedDateTimeOffsetparameters. Redis and EF Core compareDateTimeOffsetvalues. The SQLite outbox in the same package already converts to UTC withToUniversalTime()(src/NetEvolve.Pulse.SQLite/Outbox/SQLiteOutboxRepository.cs:343,:520,:734).Impact is low and does not show up in normal use. The built-in
IdempotencyStorepassesTimeProvider.GetUtcNow()for both values (src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:52,:63), so they are always+00:00. Only code that resolves the publicIIdempotencyKeyRepositoryand passes timestamps with a non-zero offset is affected, or a customTimeProviderthat breaks theGetUtcNowcontract.Specification
Project contract,
IIdempotencyKeyRepository.ExistsAsync(src/NetEvolve.Pulse.Extensibility/Idempotency/IIdempotencyKeyRepository.cs:28-33):validFromis aDateTimeOffset, so "at or after" means comparing points in time.DateTimeOffsetcomparison usesUtcDateTime(DateTimeOffset.CompareTo): "compares two DateTimeOffset objects based on their UTC date and time values". The round-trip format"O"keeps the original offset (Standard date and time format strings: the round-trip ("O", "o") format specifier), so the strings only sort in time order when every value has the same offset.Requirements
createdAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)(line 136) andvalidFrom.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)(line 111)."O"is already culture-invariant, soCultureInfo.InvariantCultureis only there for the analyzers.IdempotencyStorewrites already has+00:00.Acceptance Criteria
10:00+02:00) and checks thatExistsAsyncwithvalidFrom = 09:00+00:00returnsfalse.ExistsAsyncandStoreAsyncinSQLiteIdempotencyKeyRepositoryconvert both timestamps to UTC before comparing them.