This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 536d2e64fea9 CAMEL-25797: camel-pqc - the usage-count and 
PENDING_ROTATION key rotation signals now fire (#28094)
536d2e64fea9 is described below

commit 536d2e64fea9b615f167fdc564976d01b04d200c
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 21 14:44:52 2026 +0200

    CAMEL-25797: camel-pqc - the usage-count and PENDING_ROTATION key rotation 
signals now fire (#28094)
    
    KeyRotationScheduler documents three rotation signals, but two never fired.
    The usage count was only advanced for stateful keys, so for ML-DSA, SLH-DSA,
    Falcon and every KEM key it stayed at 0 and setMaxKeyUsage and
    camel.component.pqc.key-rotation-max-usage had no effect. A key marked
    PENDING_ROTATION was skipped by the default key filter, which only accepted
    ACTIVE keys, before needsRotation() was consulted.
    
    The producer now records and persists the usage count for every key and
    re-stores the key pair only for stateful algorithms, whose one-time index 
must
    survive a restart. A failed metadata read for a non-stateful key is logged 
and
    does not fail the exchange. The default key filter of the scheduler accepts
    ACTIVE and PENDING_ROTATION keys.
    The 4.24 upgrade guide describes the change.
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../camel/catalog/docs/pqc-key-lifecycle.adoc      |  25 +++-
 .../camel-pqc/src/main/docs/pqc-key-lifecycle.adoc |  25 +++-
 .../apache/camel/component/pqc/PQCProducer.java    |  92 +++++++++---
 .../camel/component/pqc/lifecycle/KeyMetadata.java |   7 +
 .../pqc/lifecycle/KeyRotationScheduler.java        |  18 ++-
 .../pqc/lifecycle/KeyRotationSignalsTest.java      | 159 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  25 ++++
 7 files changed, 313 insertions(+), 38 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc
index b96d97c613c4..198f29c7e57d 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc
@@ -925,10 +925,27 @@ periodically in the background and rotate the ones that 
breach a configurable po
 the scheduler is started.
 
 On every tick the scheduler lists the keys of its `KeyLifecycleManager`, keeps 
the ones matching the key filter (by
-default only `ACTIVE` keys) and, for each, delegates the decision to 
`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A
-key that needs rotation is replaced by a freshly generated key while the 
previous key is deprecated. At least one
-rotation signal must be configured - a maximum age, a maximum usage, or a 
per-key `nextRotationAt`/`PENDING_ROTATION`
-status recorded in the key metadata - otherwise no key will ever be selected.
+default `ACTIVE` and `PENDING_ROTATION` keys) and, for each, delegates the 
decision to
+`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A key that needs rotation is 
replaced by a freshly generated key while
+the previous key is deprecated. At least one rotation signal must be 
configured - a maximum age, a maximum usage, or a
+per-key `nextRotationAt`/`PENDING_ROTATION` status recorded in the key 
metadata - otherwise no key will ever be
+selected.
+
+[IMPORTANT]
+====
+`maxKeyUsage` counts *signing operations only*, and only those made through an 
endpoint that has a
+`keyLifecycleManager` configured and whose exchange carries the 
`CamelPQCKeyId` header naming the key. The count is
+recorded against the key named by that header.
+
+The key encapsulation operations do not advance the count at all, so 
`maxKeyUsage` never triggers for a KEM key - use
+`maxKeyAge` or `nextRotationAt` for those. The count is also not exact under 
concurrency; it is a rotation threshold,
+not an audit figure.
+
+Recording the usage of a non-stateful key writes to the backing store on every 
signature. On a high volume route
+against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the 
`CamelPQCKeyId` header off the signing
+exchanges. A store that cannot be read or written does not fail the exchange 
of a non-stateful key: the usage is not
+recorded, a warning is logged once per producer, and the signature is returned.
+====
 
 ._Java-only: scheduling automated key rotation_
 [source,java]
diff --git a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc 
b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
index b96d97c613c4..198f29c7e57d 100644
--- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
@@ -925,10 +925,27 @@ periodically in the background and rotate the ones that 
breach a configurable po
 the scheduler is started.
 
 On every tick the scheduler lists the keys of its `KeyLifecycleManager`, keeps 
the ones matching the key filter (by
-default only `ACTIVE` keys) and, for each, delegates the decision to 
`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A
-key that needs rotation is replaced by a freshly generated key while the 
previous key is deprecated. At least one
-rotation signal must be configured - a maximum age, a maximum usage, or a 
per-key `nextRotationAt`/`PENDING_ROTATION`
-status recorded in the key metadata - otherwise no key will ever be selected.
+default `ACTIVE` and `PENDING_ROTATION` keys) and, for each, delegates the 
decision to
+`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A key that needs rotation is 
replaced by a freshly generated key while
+the previous key is deprecated. At least one rotation signal must be 
configured - a maximum age, a maximum usage, or a
+per-key `nextRotationAt`/`PENDING_ROTATION` status recorded in the key 
metadata - otherwise no key will ever be
+selected.
+
+[IMPORTANT]
+====
+`maxKeyUsage` counts *signing operations only*, and only those made through an 
endpoint that has a
+`keyLifecycleManager` configured and whose exchange carries the 
`CamelPQCKeyId` header naming the key. The count is
+recorded against the key named by that header.
+
+The key encapsulation operations do not advance the count at all, so 
`maxKeyUsage` never triggers for a KEM key - use
+`maxKeyAge` or `nextRotationAt` for those. The count is also not exact under 
concurrency; it is a rotation threshold,
+not an audit figure.
+
+Recording the usage of a non-stateful key writes to the backing store on every 
signature. On a high volume route
+against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the 
`CamelPQCKeyId` header off the signing
+exchanges. A store that cannot be read or written does not fail the exchange 
of a non-stateful key: the usage is not
+recorded, a warning is logged once per producer, and the signature is returned.
+====
 
 ._Java-only: scheduling automated key rotation_
 [source,java]
diff --git 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
index a3a696f8f965..2a06175555de 100644
--- 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
+++ 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
@@ -27,6 +27,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import javax.crypto.KeyAgreement;
 import javax.crypto.KeyGenerator;
@@ -122,6 +123,9 @@ public class PQCProducer extends DefaultProducer {
     private KeyGenerator keyGenerator;
     private KeyPair keyPair;
 
+    // Keeps the "usage could not be recorded" warning to one line per 
producer instead of one per exchange
+    private final AtomicBoolean usageRecordFailureWarned = new AtomicBoolean();
+
     // Set only when this producer created the Signature itself, so it knows 
how to create another one.
     // Left null when the user configured an instance, which then has to be 
shared and locked instead.
     private String signerAlgorithm;
@@ -580,7 +584,7 @@ public class PQCProducer extends DefaultProducer {
         }
         exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);
 
-        persistStatefulKeyStateAfterSign(exchange);
+        recordKeyUsageAfterSign(exchange);
     }
 
     private void verification(Exchange exchange)
@@ -831,9 +835,9 @@ public class PQCProducer extends DefaultProducer {
         exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE, 
components.pqcSignature());
 
         try {
-            persistStatefulKeyStateAfterSign(exchange);
+            recordKeyUsageAfterSign(exchange);
         } catch (Exception e) {
-            throw new RuntimeCamelException("Failed to persist stateful key 
state after hybrid signing", e);
+            throw new RuntimeCamelException("Failed to record key usage after 
hybrid signing", e);
         }
     }
 
@@ -1201,21 +1205,25 @@ public class PQCProducer extends DefaultProducer {
     }
 
     /**
-     * Persists stateful key state after a signing operation through the 
KeyLifecycleManager, if configured. This
-     * ensures the key index is tracked across restarts.
+     * Records the use of the signing key through the KeyLifecycleManager, if 
one is configured and the exchange names a
+     * key.
+     * <p/>
+     * This advances {@code usageCount}, which is what the {@code maxKeyUsage} 
rotation signal of
+     * {@link org.apache.camel.component.pqc.lifecycle.KeyRotationScheduler} 
reads. It therefore runs for every signing
+     * key, not only the stateful ones: ML-DSA, SLH-DSA and Falcon keys would 
otherwise sit at a usage count of zero
+     * forever and never be rotated.
+     * <p/>
+     * The key encapsulation operations do not call this method, so a KEM key 
still does not advance its usage count and
+     * {@code maxKeyUsage} does not apply to it.
+     * <p/>
+     * For a stateful signature key the advanced private key is stored as 
well, so the one-time index survives a
+     * restart.
      */
-    private void persistStatefulKeyStateAfterSign(Exchange exchange) throws 
Exception {
+    private void recordKeyUsageAfterSign(Exchange exchange) throws Exception {
         if (keyPair == null || keyPair.getPrivate() == null) {
             return;
         }
 
-        PrivateKey privateKey = keyPair.getPrivate();
-        long remaining = getStatefulKeyRemaining(privateKey);
-        if (remaining < 0) {
-            // Not a stateful key
-            return;
-        }
-
         KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
         if (klm == null) {
             return;
@@ -1226,22 +1234,60 @@ public class PQCProducer extends DefaultProducer {
             return;
         }
 
-        // Update metadata with current usage
-        KeyMetadata metadata = klm.getKeyMetadata(keyId);
+        PrivateKey privateKey = keyPair.getPrivate();
+        boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;
+
+        KeyMetadata metadata;
+        try {
+            metadata = klm.getKeyMetadata(keyId);
+        } catch (Exception e) {
+            if (stateful) {
+                throw e;
+            }
+            // the read fails for the same reasons as the write below, and 
costs the same: only the usage count
+            warnUsageRecordFailure(keyId, e);
+            return;
+        }
         if (metadata == null) {
-            LOG.warn(
-                    "No metadata found for stateful key '{}'. The key index 
has been advanced by the signing operation "
-                     + "but cannot be persisted — on restart this index 
advance will be lost, which may lead to key reuse. "
-                     + "Ensure a KeyLifecycleManager is properly configured 
and the key is stored with metadata.",
-                    keyId);
+            if (stateful) {
+                LOG.warn(
+                        "No metadata found for stateful key '{}'. The key 
index has been advanced by the signing operation "
+                         + "but cannot be persisted — on restart this index 
advance will be lost, which may lead to key reuse. "
+                         + "Ensure a KeyLifecycleManager is properly 
configured and the key is stored with metadata.",
+                        keyId);
+            }
             return;
         }
 
         metadata.updateLastUsed();
-        klm.updateKeyMetadata(keyId, metadata);
 
-        // Persist the updated key (with new index) so state survives restarts
-        klm.storeKey(keyId, keyPair, metadata);
+        if (stateful) {
+            // storeKey writes the metadata entry as well in every manager, so 
calling updateKeyMetadata first would
+            // simply double the number of remote writes per signature
+            klm.storeKey(keyId, keyPair, metadata);
+        } else {
+            // Only the usage count is at stake here. Failing the exchange 
over it would throw away a perfectly good
+            // signature to protect a counter whose loss merely delays a 
rotation - unlike a stateful index, which
+            // cannot be recovered once it has been handed out twice.
+            try {
+                klm.updateKeyMetadata(keyId, metadata);
+            } catch (Exception e) {
+                warnUsageRecordFailure(keyId, e);
+            }
+        }
+    }
+
+    /**
+     * Warns, once per producer, that the usage of a non-stateful key was not 
recorded because its metadata could not be
+     * read from or written to the keyLifecycleManager. The signature is kept: 
only the usage count is lost.
+     */
+    private void warnUsageRecordFailure(String keyId, Exception e) {
+        if (usageRecordFailureWarned.compareAndSet(false, true)) {
+            LOG.warn("Could not record the usage of key '{}' through the 
keyLifecycleManager, so its"
+                     + " usageCount does not advance and a configured 
maxKeyUsage threshold may not be"
+                     + " reached. The signature itself is unaffected. Reported 
once per producer.",
+                    keyId, e);
+        }
     }
 
     /**
diff --git 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java
 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java
index 6bac87c69419..27e97a41ad59 100644
--- 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java
+++ 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java
@@ -78,6 +78,13 @@ public class KeyMetadata implements Serializable {
         this.lastUsedAt = lastUsedAt;
     }
 
+    /**
+     * Records one use of the key.
+     * <p/>
+     * The increment is not atomic and the managers hand out the cached 
instance, so concurrent exchanges can lose
+     * counts. That is accepted: the count only feeds a rotation threshold, 
where undercounting delays a rotation rather
+     * than breaking anything. Do not rely on it as an exact number of 
operations.
+     */
     public void updateLastUsed() {
         this.lastUsedAt = Instant.now();
         this.usageCount++;
diff --git 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java
 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java
index 93a319f86347..f873c408c17d 100644
--- 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java
+++ 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java
@@ -54,10 +54,11 @@ import org.slf4j.LoggerFactory;
  * }</pre>
  *
  * On every tick the scheduler lists the managed keys, keeps only those 
matching the configured {@link #setKeyFilter key
- * filter} (by default only {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} keys) 
and, for each of them, delegates the
- * decision to {@link KeyLifecycleManager#needsRotation(String, Duration, 
long)}. When a key needs rotation, the id of
- * its replacement is produced by the configured {@link #setKeyIdStrategy key 
id strategy} (by default the previous key
- * id with a rotation marker and timestamp appended).
+ * filter} (by default {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} and {@link 
KeyMetadata.KeyStatus#PENDING_ROTATION
+ * PENDING_ROTATION} keys) and, for each of them, delegates the decision to
+ * {@link KeyLifecycleManager#needsRotation(String, Duration, long)}. When a 
key needs rotation, the id of its
+ * replacement is produced by the configured {@link #setKeyIdStrategy key id 
strategy} (by default the previous key id
+ * with a rotation marker and timestamp appended).
  * <p/>
  * At least one rotation signal should be configured (a {@link #setMaxKeyAge 
maximum age}, a {@link #setMaxKeyUsage
  * maximum usage}, or a per-key {@code nextRotationAt}/{@code 
PENDING_ROTATION} status recorded in the key metadata);
@@ -76,7 +77,10 @@ public class KeyRotationScheduler extends ServiceSupport 
implements CamelContext
     private Duration checkInterval = Duration.ofHours(1);
     private Duration maxKeyAge;
     private long maxKeyUsage;
-    private Predicate<KeyMetadata> keyFilter = metadata -> 
metadata.getStatus() == KeyMetadata.KeyStatus.ACTIVE;
+    // PENDING_ROTATION has to pass the filter too: it is one of the 
documented rotation signals, and a key an
+    // operator marked that way is by definition no longer ACTIVE
+    private Predicate<KeyMetadata> keyFilter = metadata -> 
metadata.getStatus() == KeyMetadata.KeyStatus.ACTIVE
+            || metadata.getStatus() == KeyMetadata.KeyStatus.PENDING_ROTATION;
     private Function<KeyMetadata, String> keyIdStrategy = 
KeyRotationScheduler::defaultRotatedKeyId;
     private KeyRotationListener listener;
 
@@ -269,8 +273,8 @@ public class KeyRotationScheduler extends ServiceSupport 
implements CamelContext
     }
 
     /**
-     * Selects which keys are considered for rotation. Defaults to only {@link 
KeyMetadata.KeyStatus#ACTIVE ACTIVE}
-     * keys.
+     * Selects which keys are considered for rotation. Defaults to {@link 
KeyMetadata.KeyStatus#ACTIVE ACTIVE} and
+     * {@link KeyMetadata.KeyStatus#PENDING_ROTATION PENDING_ROTATION} keys.
      */
     public KeyRotationScheduler setKeyFilter(Predicate<KeyMetadata> keyFilter) 
{
         this.keyFilter = ObjectHelper.notNull(keyFilter, "keyFilter");
diff --git 
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java
 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java
new file mode 100644
index 000000000000..affabe24d4f1
--- /dev/null
+++ 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.pqc.lifecycle;
+
+import java.security.KeyPair;
+import java.security.Security;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.pqc.PQCConstants;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The two rotation signals that never fired: usageCount was only advanced for 
stateful keys, and PENDING_ROTATION was
+ * filtered out before needsRotation() was ever consulted.
+ */
+public class KeyRotationSignalsTest extends CamelTestSupport {
+
+    private static final String KEY_ID = "signing-key";
+
+    private static void registerProviders() {
+        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+            Security.addProvider(new BouncyCastleProvider());
+        }
+        if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) == 
null) {
+            Security.addProvider(new BouncyCastlePQCProvider());
+        }
+    }
+
+    private final FailingReadKeyLifecycleManager keyManager = new 
FailingReadKeyLifecycleManager();
+
+    /**
+     * An in-memory manager whose metadata reads can be made to fail, like a 
vault that is unreachable.
+     */
+    private static final class FailingReadKeyLifecycleManager extends 
InMemoryKeyLifecycleManager {
+        private volatile boolean failReads;
+
+        @Override
+        public KeyMetadata getKeyMetadata(String keyId) {
+            if (failReads) {
+                throw new IllegalStateException("simulated vault outage");
+            }
+            return super.getKeyMetadata(keyId);
+        }
+    }
+
+    private KeyPair signingKeyPair;
+
+    @EndpointInject("mock:sign")
+    protected MockEndpoint resultSign;
+
+    @Produce("direct:sign")
+    protected ProducerTemplate templateSign;
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() throws Exception {
+                // re-registered per route build: until CAMEL-25795 lands, 
stopping the previous test's context
+                // de-registers the providers for the whole JVM
+                registerProviders();
+                context.getRegistry().bind("keyManager", keyManager);
+                // the key that signs is the one the usage is recorded 
against: recordKeyUsageAfterSign counts
+                // against the key named by CamelPQCKeyId, so the endpoint 
signs with that very key pair
+                signingKeyPair = keyManager.generateKeyPair("MLDSA", KEY_ID);
+                context.getRegistry().bind("signingKeyPair", signingKeyPair);
+                from("direct:sign")
+                        
.to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager"
+                            + "&keyPair=#signingKeyPair")
+                        .to("mock:sign");
+                // the key status check before signing is a deliberate 
fail-closed read of the same metadata; this
+                // route turns it off, so the only vault read of an exchange 
is the one recording the usage
+                from("direct:signNoStatusCheck")
+                        
.to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager"
+                            + 
"&keyPair=#signingKeyPair&strictKeyLifecycle=false")
+                        .to("mock:sign");
+            }
+        };
+    }
+
+    @Test
+    void signingAStatelessKeyAdvancesTheUsageCount() throws Exception {
+        assertEquals(0, keyManager.getKeyMetadata(KEY_ID).getUsageCount());
+
+        resultSign.expectedMessageCount(3);
+        for (int i = 0; i < 3; i++) {
+            templateSign.sendBodyAndHeader("Hello", PQCConstants.KEY_ID, 
KEY_ID);
+        }
+        resultSign.assertIsSatisfied();
+
+        // ML-DSA is not a stateful key; before the fix the counter stayed at 
zero and maxKeyUsage never fired
+        assertEquals(3, keyManager.getKeyMetadata(KEY_ID).getUsageCount(),
+                "every signing operation must advance the usage count");
+
+        assertTrue(keyManager.needsRotation(KEY_ID, null, 3),
+                "a key at its maxKeyUsage must be reported as needing 
rotation");
+    }
+
+    @Test
+    void aFailedMetadataReadKeepsTheSignatureOfAStatelessKey() throws 
Exception {
+        resultSign.expectedMessageCount(1);
+
+        keyManager.failReads = true;
+        try {
+            template.sendBodyAndHeader("direct:signNoStatusCheck", "Hello", 
PQCConstants.KEY_ID, KEY_ID);
+        } finally {
+            keyManager.failReads = false;
+        }
+
+        resultSign.assertIsSatisfied();
+        // ML-DSA is not stateful, so no one-time index was consumed: only the 
usage count is lost
+        
assertNotNull(resultSign.getExchanges().get(0).getMessage().getHeader(PQCConstants.SIGNATURE,
 byte[].class),
+                "a vault that cannot be read must not cost the signature of a 
stateless key");
+        assertEquals(0, keyManager.getKeyMetadata(KEY_ID).getUsageCount(),
+                "the usage that could not be recorded must not be counted 
afterwards");
+    }
+
+    @Test
+    void aPendingRotationKeyIsSelectedByTheDefaultFilter() throws Exception {
+        KeyMetadata metadata = keyManager.getKeyMetadata(KEY_ID);
+        metadata.setStatus(KeyMetadata.KeyStatus.PENDING_ROTATION);
+        keyManager.updateKeyMetadata(KEY_ID, metadata);
+
+        KeyRotationScheduler scheduler = new KeyRotationScheduler(keyManager);
+        scheduler.setCamelContext(context);
+
+        
assertTrue(scheduler.getKeyFilter().test(keyManager.getKeyMetadata(KEY_ID)),
+                "the default key filter must not drop a PENDING_ROTATION key 
before needsRotation() is consulted");
+
+        int rotated = scheduler.checkAndRotate();
+
+        assertEquals(1, rotated, "a PENDING_ROTATION key must be rotated by 
the default configuration");
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 58cacfbf022b..5587787d422e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -339,6 +339,31 @@ local model gets a core set of tools (`--tools=auto`, the 
default), a hosted mod
 of `--max-iterations` is now 25 instead of 10, and `--model llama3.2` now 
selects that model also when a
 model better at tool calling, such as `qwen3.6`, is installed.
 
+=== camel-pqc - the usage-count and PENDING_ROTATION key rotation signals now 
fire
+
+Two of the three documented rotation signals of `KeyRotationScheduler` 
previously never triggered. Both now work, so a
+deployment that already configured them will start rotating keys where before 
nothing happened.
+
+* `maxKeyUsage` (and `camel.component.pqc.key-rotation-max-usage`) read 
`usageCount`, which was only advanced for the
+  stateful signature algorithms (XMSS, XMSSMT, LMS, HSS). For ML-DSA, SLH-DSA, 
Falcon and the KEM keys the counter
+  stayed at zero forever. A signing operation now advances it for every key, 
provided a `keyLifecycleManager` is
+  configured and the exchange carries a `CamelPQCKeyId` header.
+* The scheduler's default key filter accepted only `ACTIVE` keys, so a key an 
operator had marked
+  `PENDING_ROTATION` was skipped before `needsRotation()` was consulted. The 
default filter now accepts `ACTIVE` and
+  `PENDING_ROTATION`. A custom filter set with `setKeyFilter` is unaffected.
+
+Review any configured `maxKeyUsage` threshold before upgrading: a long-running 
deployment may rotate on the first
+check once the counter starts advancing.
+
+Recording the usage also costs a write. A signing endpoint with a 
`keyLifecycleManager` and a `CamelPQCKeyId` header
+now updates the key metadata in the backing store on every signature, where a 
non-stateful key previously wrote
+nothing. Against AWS Secrets Manager, Azure Key Vault or HashiCorp Vault that 
is a remote call per message. A failure
+to read or write that metadata does not fail the exchange for a non-stateful 
key - it is logged once per producer and
+the signature is returned - because losing a usage count only delays a 
rotation. Leave the `CamelPQCKeyId` header off
+the signing exchanges, or use `maxKeyAge` or `nextRotationAt`, if the write is 
not wanted.
+
+The key encapsulation operations do not record usage, so `maxKeyUsage` still 
never triggers for a KEM key.
+
 === camel-jbang - camel get endpoint shows the scope of an endpoint
 
 `camel get endpoint` replaces the `REMOTE` column with `SCOPE`, which tells 
three kinds of endpoint apart:

Reply via email to