diff --git a/CHANGELOG.md b/CHANGELOG.md
index a520a8c..6177c46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
* `PerformanceBuilder.EnableQueryCaching()` — compose the existing `WithCaching(c => c.UseMemoryCache())` configuration with your query path; QuerySpec does not own a query-result cache.
* `PerformanceBuilder.OptimizeExpressions()` — no replacement; expression compilation is already handled by EF Core / `IQueryable` providers.
* `SecurityBuilder.RotateKeysEvery(int)` — implement key rotation at the storage layer following the same pattern as the `RotateKeyAsync` removal in [#138](https://github.com/AbongileBoja/QuerySpec/issues/138). Closes [#139](https://github.com/AbongileBoja/QuerySpec/issues/139).
+* **security:** `IEncryptionProvider.RotateKeyAsync()` and `IEncryptionProvider.RotateKeyAsync(CancellationToken)` removed. Both overloads were `[Obsolete(error: true)]` in 3.x and every shipping implementation (`AesEncryptionProvider`, `AesGcmEncryptionProvider`, `MigratingEncryptionProvider`) threw `NotSupportedException`. ApiCompat reports `CP0001` (member removed) on both. Implement key rotation at the storage layer instead: construct a new provider with the new key, decrypt under the old provider, re-encrypt under the new provider, persist, then cut over reads. For `MigratingEncryptionProvider` specifically, the existing prefix-tag dispatch already supports this pattern — register the previous writer as a legacy reader and promote the new writer. Closes [#138](https://github.com/AbongileBoja/QuerySpec/issues/138).
### Added
diff --git a/src/QuerySpec.Core/Security/AesEncryptionProvider.cs b/src/QuerySpec.Core/Security/AesEncryptionProvider.cs
index 5e81649..59acbfa 100644
--- a/src/QuerySpec.Core/Security/AesEncryptionProvider.cs
+++ b/src/QuerySpec.Core/Security/AesEncryptionProvider.cs
@@ -110,20 +110,6 @@ public string Decrypt(string ciphertext)
return Encoding.UTF8.GetString(plainBytes);
}
- ///
- /// Key rotation requires re-encrypting every ciphertext under the new key. This provider
- /// does not own the persisted ciphertexts. The method previously returned silently which
- /// gave callers a false belief that rotation had happened. Deprecated and will be removed
- /// in 3.0; implement rotation at the storage layer.
- ///
- /// Always thrown.
- [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)]
- public Task RotateKeyAsync() =>
- throw new NotSupportedException(
- "AesEncryptionProvider does not own the persisted ciphertexts and cannot rotate. " +
- "Construct a new provider with the new key, decrypt with the old, re-encrypt with the new at the storage layer. " +
- "Prefer AesGcmEncryptionProvider for new ciphertexts.");
-
/// Generates a new 256-bit encryption key.
/// A base64-encoded 32-byte AES key suitable for the constructor.
public static string GenerateKey()
diff --git a/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs b/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs
index f84216a..7ad9723 100644
--- a/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs
+++ b/src/QuerySpec.Core/Security/AesGcmEncryptionProvider.cs
@@ -102,20 +102,6 @@ public string Decrypt(string ciphertext, ReadOnlySpan associatedData)
return Encoding.UTF8.GetString(plain);
}
- ///
- /// Key rotation requires re-encrypting every ciphertext under the new key. This provider
- /// holds a single immutable key and does not own the persisted ciphertexts; callers must
- /// implement rotation at their storage layer (decrypt under old key, re-encrypt under new
- /// key). The method is implemented as a throwing stub so callers cannot mistake a no-op
- /// for a real rotation. Deprecated and will be removed in 3.0.
- ///
- /// Always thrown.
- [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)]
- public Task RotateKeyAsync() =>
- throw new NotSupportedException(
- "AesGcmEncryptionProvider does not own the persisted ciphertexts and therefore cannot rotate. " +
- "Construct a new instance with the new key, decrypt with the old, re-encrypt with the new at the storage layer.");
-
/// Generates a new 256-bit AES key, base64-encoded.
public static string GenerateKey()
{
diff --git a/src/QuerySpec.Core/Security/IEncryptionProvider.cs b/src/QuerySpec.Core/Security/IEncryptionProvider.cs
index a26ce4c..05b8c14 100644
--- a/src/QuerySpec.Core/Security/IEncryptionProvider.cs
+++ b/src/QuerySpec.Core/Security/IEncryptionProvider.cs
@@ -1,7 +1,3 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-
namespace QuerySpec.Core.Security;
///
@@ -17,25 +13,4 @@ public interface IEncryptionProvider
/// Provider-defined ciphertext envelope as produced by .
/// The recovered UTF-8 plaintext.
string Decrypt(string ciphertext);
-
- ///
- /// Rotates the encryption key. Deprecated: every shipping implementation throws
- /// because the provider does not own the persisted
- /// ciphertexts. Implement rotation at the storage layer (Azure Key Vault, AWS KMS, etc.):
- /// decrypt under the old provider, re-encrypt under the new provider.
- ///
- /// A task that completes once the rotation has been performed by the implementation.
- [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)]
- Task RotateKeyAsync();
- ///
- /// Cancellation-aware overload of . Deprecated alongside the
- /// no-token overload; will be removed in 3.0. Implement key rotation at the storage layer.
- ///
- /// Token observed by implementations that perform I/O during rotation.
- /// A task that completes once the rotation has been performed by the implementation.
- [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)]
- Task RotateKeyAsync(CancellationToken cancellationToken)
-#pragma warning disable CS0619 // Obsolete-error self-reference: the DIM delegates to the CT-less overload that is itself obsolete; both ship as a coupled deprecation pair removed together in 3.0.
- => RotateKeyAsync();
-#pragma warning restore CS0619
}
diff --git a/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs b/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs
index a4f033d..19b7033 100644
--- a/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs
+++ b/src/QuerySpec.Core/Security/MigratingEncryptionProvider.cs
@@ -128,20 +128,6 @@ public string Decrypt(string ciphertext)
/// The tag stamped on every ciphertext written by this instance.
public string WriteTag { get; }
- ///
- /// Migration is meaningful only at the storage layer (decrypt under reader, re-encrypt
- /// under writer). Calling this on the wrapper is almost always a mistake — it would
- /// rotate the inner writer's key without re-encrypting any persisted ciphertexts and
- /// would invalidate the readers for tags pointing at the same physical provider.
- /// Deprecated and will be removed in 3.0.
- ///
- /// Always thrown.
- [Obsolete("Key rotation is a storage-layer concern; this method will be removed in 3.0. Implement rotation in the storage layer (Azure Key Vault, AWS KMS, etc.) rather than on IEncryptionProvider.", error: true)]
- public Task RotateKeyAsync() =>
- throw new NotSupportedException(
- "MigratingEncryptionProvider does not own the inner providers' keys and cannot rotate. " +
- "Construct a new instance with the new writer (and the previous writer demoted to a reader) and re-encrypt at the storage layer.");
-
internal static void ValidateTag(string tag, string paramName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag, paramName);
diff --git a/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs b/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs
index a479a67..0b52d45 100644
--- a/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs
+++ b/tests/QuerySpec.Core.Tests/Security/AesGcmEncryptionProviderTests.cs
@@ -155,16 +155,6 @@ public void EnvelopeLength_Equals_NoncePlusTagPlusUtf8PlaintextLength()
Assert.Equal(12 + 16 + utf8Len, bytes.Length);
}
- [Fact]
- public void RotateKeyAsync_Throws_NotSupported()
- {
- var p = new AesGcmEncryptionProvider(NewKey());
-
- var method = typeof(AesGcmEncryptionProvider).GetMethod(nameof(IEncryptionProvider.RotateKeyAsync), Type.EmptyTypes)!;
- var ex = Assert.Throws(() => method.Invoke(p, null));
- Assert.IsType(ex.InnerException);
- }
-
[Fact]
public void GenerateKey_Produces_Base64_32Bytes()
{
diff --git a/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs b/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs
index 9e155b8..177745c 100644
--- a/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs
+++ b/tests/QuerySpec.Core.Tests/Security/MigratingEncryptionProviderTests.cs
@@ -142,16 +142,6 @@ public void Aead_Constructor_NullWriter_Throws()
new MigratingAuthenticatedEncryptionProvider("v2", writer: null!));
}
- [Fact]
- public void RotateKeyAsync_AlwaysThrows()
- {
- var migrating = new MigratingEncryptionProvider("v2", new AesGcmEncryptionProvider(NewKeyB64()));
-
- var method = typeof(MigratingEncryptionProvider).GetMethod(nameof(IEncryptionProvider.RotateKeyAsync), Type.EmptyTypes)!;
- var ex = Assert.Throws(() => method.Invoke(migrating, null));
- Assert.IsType(ex.InnerException);
- }
-
[Fact]
public void RegisteredTags_IncludesWriterAndReaders()
{