You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/29 13:49:39 UTC

(juneau) branch master updated: feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation

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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 830e0ca051 feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation
830e0ca051 is described below

commit 830e0ca051c604213fa6a03ad16a8deef38286e7
Author: James Bognar <ja...@apache.org>
AuthorDate: Fri May 29 09:49:33 2026 -0400

    feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation
---
 .../juneau/commons/bean/BeanPropertyMeta.java      |   65 +-
 .../juneau/commons/logging/LogRecordCapture.java   |   19 +-
 .../org/apache/juneau/commons/logging/Logger.java  |   17 +-
 .../juneau/commons/reflect/ParameterInfo.java      |   10 +-
 juneau-distrib/pom.xml                             |   25 +
 .../org/apache/juneau/rest/auth/jwt/JwksCache.java |  113 ++-
 .../juneau/rest/auth/jwt/JwtTokenValidator.java    |   82 +-
 .../micrometer/MicrometerMetricsRecorder.java      |   22 +-
 .../oauth/flow/OAuthAuthorizationCodeFlow.java     |   41 +
 juneau-rest/juneau-rest-server-oidc-rp/pom.xml     |  128 +++
 .../juneau/rest/auth/oidc/rp/EphemeralStore.java   |  154 +++
 .../rest/auth/oidc/rp/IdTokenValidatorAdapter.java |  253 +++++
 .../rest/auth/oidc/rp/InMemorySessionStore.java    |  198 ++++
 .../juneau/rest/auth/oidc/rp/OidcRelyingParty.java | 1018 ++++++++++++++++++++
 .../juneau/rest/auth/oidc/rp/OidcSession.java      |   91 ++
 .../rest/auth/oidc/rp/OidcSessionAuthFilter.java   |   87 ++
 .../juneau/rest/auth/oidc/rp/SessionStore.java     |  129 +++
 .../auth/oidc/rp/SignedCookieSessionStore.java     |  232 +++++
 .../juneau/rest/auth/oidc/rp/package-info.java     |   38 +
 .../juneau/rest/tracing/otel/OtelTracerHook.java   |   26 +
 .../java/org/apache/juneau/rest/RestContext.java   |  267 ++++-
 .../java/org/apache/juneau/rest/RestOpContext.java |  147 ++-
 .../java/org/apache/juneau/rest/RestOpInvoker.java |   24 +-
 .../apache/juneau/rest/RestServerConstants.java    |   37 +
 .../org/apache/juneau/rest/annotation/Rest.java    |   94 ++
 .../juneau/rest/annotation/RestAnnotation.java     |   38 +
 .../apache/juneau/rest/annotation/RestDelete.java  |   64 ++
 .../rest/annotation/RestDeleteAnnotation.java      |   76 ++
 .../org/apache/juneau/rest/annotation/RestGet.java |   64 ++
 .../juneau/rest/annotation/RestGetAnnotation.java  |   76 ++
 .../org/apache/juneau/rest/annotation/RestOp.java  |   98 ++
 .../juneau/rest/annotation/RestOpAnnotation.java   |   76 ++
 .../apache/juneau/rest/annotation/RestOptions.java |   64 ++
 .../rest/annotation/RestOptionsAnnotation.java     |   76 ++
 .../apache/juneau/rest/annotation/RestPatch.java   |   64 ++
 .../rest/annotation/RestPatchAnnotation.java       |   76 ++
 .../apache/juneau/rest/annotation/RestPost.java    |   64 ++
 .../juneau/rest/annotation/RestPostAnnotation.java |   76 ++
 .../org/apache/juneau/rest/annotation/RestPut.java |   64 ++
 .../juneau/rest/annotation/RestPutAnnotation.java  |   76 ++
 .../juneau/rest/metrics/MetricsRecorder.java       |    8 +-
 .../juneau/rest/metrics/NoOpMetricsRecorder.java   |    2 +-
 .../rest/processor/AsyncResponseProcessor.java     |   30 +-
 .../juneau/rest/processor/MdcAsyncListener.java    |  205 ++++
 .../tracing/TraceContextResponseProcessor.java     |  122 +++
 juneau-rest/pom.xml                                |    1 +
 juneau-utest/pom.xml                               |   11 +
 .../bean/BeanPropertyMeta_AbstractSet_Test.java    |  165 ++++
 .../apache/juneau/commons/inject/Value_Test.java   |   62 +-
 .../apache/juneau/commons/logging/Logger_Test.java |   20 +
 .../reflect/ParameterInfo_OptionalValue_Test.java  |  184 ++++
 .../observability/ObservabilityNoopBenchmark.java  |    2 +-
 .../rest/async/AsyncCompletionExecutor_Test.java   |  325 +++++++
 .../juneau/rest/auth/jwt/JwksCache_Test.java       |  309 +++++-
 .../auth/jwt/JwtTokenValidator_Builder_Test.java   |   44 +
 .../jwt/JwtTokenValidator_ValueAdoption_Test.java  |   54 +-
 .../rest/auth/oidc/rp/EphemeralStore_Test.java     |  119 +++
 .../auth/oidc/rp/IdTokenValidatorAdapter_Test.java |  112 +++
 .../auth/oidc/rp/InMemorySessionStore_Test.java    |  113 +++
 .../rest/auth/oidc/rp/OidcRelyingParty_Test.java   |  290 ++++++
 .../juneau/rest/auth/oidc/rp/OidcTestSupport.java  |  232 +++++
 .../oidc/rp/SignedCookieSessionStore_Test.java     |  107 ++
 .../metrics/MetricsRecorder_Contract_Test.java     |    6 +-
 .../micrometer/MicrometerMetricsRecorder_Test.java |   12 +-
 .../RestObservabilityAttributes_Test.java          |  251 +++++
 .../observability/RestObservabilitySvl_Test.java   |  128 +++
 .../TraceContextResponseProcessor_Test.java        |  202 ++++
 .../rest/processor/MdcAsyncListener_Test.java      |  217 +++++
 juneau-utest/test-run-history.tsv                  |    1 +
 69 files changed, 7599 insertions(+), 104 deletions(-)

diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
index 74eea2c4aa..b232341d2a 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
@@ -1217,25 +1217,24 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
 
 						if (propertyClass.isInstance(valueList) || (nn(setter) && setter.getParameterTypes().get(0).is(Collection.class))) {
 							if (! elementType.isObject()) {
-								var l = new ArrayList<>(valueList);
-								for (var i = l.listIterator(); i.hasNext();) {
-									var v = i.next();
+								// Element coercion is required.  Build the coerced elements into a concrete collection
+								// whose shape is assignable to the (abstract) property type — never a bare ArrayList —
+								// so Deque/Queue/SortedSet fields aren't handed an incompatible container.  (The source
+								// value can incidentally satisfy propertyClass.isInstance(...) — e.g. the parser's
+								// generic list is a LinkedList, which is-a Deque/Queue — yet still need coercion.)
+								Collection coerced = createDefaultCollectionForAbstractType(propertyClass);
+								for (var v : valueList) {
 									var needsConversion = v == null ? elementType.isOptional() : ! elementType.isInstance(v);
-									if (needsConversion)
-										i.set(session.convertToType(v, elementType));
+									coerced.add(needsConversion ? session.convertToType(v, elementType) : v);
 								}
-								valueList = l;
+								valueList = coerced;
 							}
 							invokeSetter(bean, pName, valueList);
 							return r;
 						}
 
+						// Robust best-effort-then-fallback materialization — never returns null (see helper Javadoc).
 						propList = createDefaultCollectionForAbstractType(propertyClass);
-						if (propList == null) {
-							throw bex(beanMeta.getBeanInfo(),
-								"Cannot set property ''{0}'' of type ''{1}'' to object of type ''{2}'' because the assigned map cannot be converted to the specified type because the property type is abstract, and the property value is currently null",
-								name, propertyClass.getName(), cn(value1));
-						}
 						invokeSetter(bean, pName, propList);
 					}
 					propList.clear();
@@ -1370,16 +1369,48 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
 		return beanMeta.getClassInfo().getName();
 	}
 
+	/**
+	 * Returns a concrete {@link Collection} instance suitable for populating an abstract collection-typed bean
+	 * property that has no setter and no {@code @BeanProp(type=...)} override.
+	 *
+	 * <p>
+	 * Resolution is <b>best-effort-then-fallback</b>:
+	 * <ol>
+	 * 	<li><b>Best-effort materialization of the declared type.</b>  First attempts to instantiate
+	 * 		{@code propertyClass} directly via {@link BeanInstantiator} (preferring a zero-arg constructor).  This
+	 * 		succeeds when the declared type is itself instantiable — e.g. a concrete {@link AbstractSet} /
+	 * 		{@link AbstractList} subclass with an accessible no-arg constructor, or any type
+	 * 		{@code BeanInstantiator} can otherwise construct.  The attempt never throws; on any failure it silently
+	 * 		degrades to the shape-based fallback below.
+	 * 	<li><b>Shape-based fallback.</b>  When the declared type is genuinely not instantiable (a {@link Set} /
+	 * 		{@link List} / {@link Queue} interface, a truly abstract class, or a class with no usable constructor),
+	 * 		a default concrete type is chosen by collection shape:
+	 * 		<ul>
+	 * 			<li>{@link SortedSet} / {@link NavigableSet} &rarr; {@link TreeSet}
+	 * 			<li>{@link Set} &rarr; {@link LinkedHashSet} (preserves insertion order)
+	 * 			<li>{@link Deque} / {@link Queue} &rarr; {@link ArrayDeque}
+	 * 			<li>{@link List} / raw {@link Collection} (and any other collection shape) &rarr; {@link ArrayList}
+	 * 		</ul>
+	 * </ol>
+	 *
+	 * @param propertyClass The declared (abstract or interface) collection type of the property.
+	 * @return A new, empty, concrete {@link Collection} instance.  Never <jk>null</jk>.
+	 */
 	private Collection<?> createDefaultCollectionForAbstractType(Class<?> propertyClass) {
-		if (propertyClass == SortedSet.class || propertyClass == NavigableSet.class)
+		// Best-effort: try to materialize the declared type itself (handles concrete AbstractSet/AbstractList
+		// subclasses, registered defaults, etc.).  Never throws — degrades to the shape-based fallback on failure.
+		Collection declared = BeanInstantiator.of(Collection.class).type(info(propertyClass)).preferZeroArgConstructor().fallback(() -> null).run();
+		if (nn(declared))
+			return declared;
+
+		// Shape-based fallback for genuinely non-instantiable declared types.
+		if (SortedSet.class.isAssignableFrom(propertyClass) || NavigableSet.class.isAssignableFrom(propertyClass))
 			return new TreeSet<>();
-		if (propertyClass == Set.class)
+		if (Set.class.isAssignableFrom(propertyClass))
 			return new LinkedHashSet<>();
-		if (propertyClass == Deque.class || propertyClass == Queue.class)
+		if (Deque.class.isAssignableFrom(propertyClass) || Queue.class.isAssignableFrom(propertyClass))
 			return new ArrayDeque<>();
-		if (propertyClass == List.class || propertyClass == Collection.class)
-			return new ArrayList<>();
-		return null;
+		return new ArrayList<>();  // List or raw Collection (and any other collection shape).
 	}
 
 	/**
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
index 85c31bce7b..3a66a35d35 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
@@ -20,7 +20,6 @@ import java.io.Closeable;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.logging.Level;
 
 /**
  * Captures log records for testing purposes.
@@ -62,17 +61,6 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
 	private final Logger logger;
 	private final List<LogRecord> records = Collections.synchronizedList(new ArrayList<>());
 
-	/**
-	 * The logger level saved before capture started, restored on {@link #close()}.
-	 *
-	 * <p>
-	 * Saved and restored so that {@link Logger#isLoggable(Level)} always returns {@code true}
-	 * for every level while capture is active. Without this, certain JDK 25 implementations
-	 * skip the {@code doLog} body when the effective level is coarser than the logged level
-	 * (e.g. INFO when a FINE record is emitted), resulting in 0 captured records.
-	 */
-	private final Level savedLevel;
-
 	/**
 	 * Constructor.
 	 *
@@ -80,10 +68,6 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
 	 */
 	LogRecordCapture(Logger logger) {
 		this.logger = logger;
-		this.savedLevel = logger.getLevel();
-		// Force level to ALL so every log record reaches our listener regardless
-		// of the logger's configured level on any JDK version.
-		logger.setLevel(Level.ALL);
 		logger.addLogRecordListener(this);
 	}
 
@@ -152,11 +136,10 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
 	}
 
 	/**
-	 * Closes this capture, removes it from the logger's listeners, and restores the logger level.
+	 * Closes this capture and removes it from the logger's listeners.
 	 */
 	@Override
 	public void close() {
 		logger.removeLogRecordListener(this);
-		logger.setLevel(savedLevel);
 	}
 }
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
index 12aeb7b4da..0f3e84396e 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
@@ -22,10 +22,10 @@ import static java.util.logging.Level.*;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.logging.Handler;
 import java.util.logging.Level;
 
-import org.apache.juneau.commons.collections.Cache;
 import org.apache.juneau.commons.utils.Utils;
 
 /**
@@ -84,13 +84,18 @@ import org.apache.juneau.commons.utils.Utils;
 public class Logger extends java.util.logging.Logger {
 
 	/**
-	 * Cache of logger instances by name.
+	 * Permanent registry of logger instances by name.
 	 *
 	 * <p>
-	 * Logger instances are cached and reused for the same name, ensuring that listeners
-	 * attached to a logger persist across multiple calls to {@link #getLogger(String)}.
+	 * Uses an unbounded {@link ConcurrentHashMap} so every name maps to exactly one
+	 * {@link Logger} instance for the JVM lifetime.  An evictable cache (e.g. one with a
+	 * {@code maxSize} that clears the whole map) would cause a class-loaded
+	 * {@code static final Logger} field to refer to an instance that is no longer in the
+	 * registry; a subsequent call to {@link #getLogger(String)} would then return a new,
+	 * different instance, breaking {@link LogRecordCapture} and any other code that
+	 * assumes one name == one identity.
 	 */
-	private static final Cache<String, Logger> loggers = Cache.of(String.class, Logger.class).supplier(k -> new Logger(java.util.logging.Logger.getLogger(k))).build();
+	private static final ConcurrentHashMap<String, Logger> loggers = new ConcurrentHashMap<>();
 
 	/**
 	 * The underlying logger instance that we delegate to.
@@ -125,7 +130,7 @@ public class Logger extends java.util.logging.Logger {
 	 * @return A logger instance (cached and reused for the same name).
 	 */
 	public static Logger getLogger(String name) {
-		return loggers.get(name);
+		return loggers.computeIfAbsent(name, k -> new Logger(java.util.logging.Logger.getLogger(k)));
 	}
 
 	/**
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
index 6105948335..822c21e947 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
@@ -904,7 +904,15 @@ public class ParameterInfo extends ElementInfo implements Annotatable {
 			// Supplier<String> field/parameter type opts in to re-evaluating reads.
 			// BeanStore is forwarded so caller-scoped PropertySource beans (e.g. per-RestContext
 			// @Rest(config=...) Configs) participate in expression resolution alongside Settings.
-			return ValueResolver.resolve(valueExpr, ptu.inner(), inner.getParameterizedType(), this.toString(), beanStore);
+			var resolved = ValueResolver.resolve(valueExpr, ptu.inner(), inner.getParameterizedType(), this.toString(), beanStore);
+			// Mirror FieldInfo.inject: wrap in Optional when the declared parameter type is Optional<T>.
+			// VarResolver substitutes "" for a missing key with no default — collapse both null and ""
+			// to Optional.empty() so @Value("${maybe}") Optional<T> behaves the same as Spring's.
+			if (pt.is(Optional.class))
+				return (resolved == null || (resolved instanceof String s && s.isEmpty()))
+					? Optional.empty()
+					: Optional.of(resolved);
+			return resolved;
 		}
 
 		if (JsrSupport.isProviderType(ptu.inner())) {
diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index 18823486ed..ecd2b7e729 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -558,6 +558,31 @@
 									<destFileName>org.apache.juneau.rest.server.oauth_${project.version}.jar</destFileName>
 								</artifactItem>
 
+								<!-- juneau-rest-server-oidc-rp -->
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-oidc-rp</artifactId>
+									<version>${project.version}</version>
+									<type>jar</type>
+									<classifier>sources</classifier>
+									<destFileName>apache-juneau-rest-server-oidc-rp-${project.version}-sources.jar</destFileName>
+								</artifactItem>
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/bin/lib</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-oidc-rp</artifactId>
+									<version>${project.version}</version>
+									<destFileName>apache-juneau-rest-server-oidc-rp-${project.version}.jar</destFileName>
+								</artifactItem>
+								<artifactItem>
+									<outputDirectory>${project.build.directory}/bin/osgi</outputDirectory>
+									<groupId>org.apache.juneau</groupId>
+									<artifactId>juneau-rest-server-oidc-rp</artifactId>
+									<version>${project.version}</version>
+									<destFileName>org.apache.juneau.rest.server.oidc.rp_${project.version}.jar</destFileName>
+								</artifactItem>
+
 								<!-- juneau-rest-server-view-jsp -->
 								<artifactItem>
 									<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
diff --git a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
index ec40eb41c0..b6d3c9653b 100644
--- a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
+++ b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.auth.jwt;
 
 import java.time.*;
 import java.util.*;
+import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
 import java.util.logging.*;
 
@@ -27,7 +28,8 @@ import com.nimbusds.jose.jwk.source.*;
 import com.nimbusds.jose.proc.*;
 
 /**
- * Time-bounded cache of {@link JWKSet} keys with graceful-degradation on fetch failure.
+ * Time-bounded cache of {@link JWKSet} keys with graceful-degradation on fetch failure
+ * and optional eager refresh on a key-id ({@code kid}) cache miss.
  *
  * <p>
  * Wraps a {@link JWKSource} so that:
@@ -36,6 +38,22 @@ import com.nimbusds.jose.proc.*;
  * 	<li>If the underlying source fails (network down, JWKS endpoint returning 5xx), the cache
  * 		continues to return the last-known-good key set with a warning logged at {@code WARNING}
  * 		level. This avoids correlated auth outages caused by transient network blips.
+ * 	<li>When {@code eagerRefreshOnKidMiss} is {@code true} (the default when constructed via
+ * 		{@link JwtTokenValidator.Builder}), a fresh-cache selection that returns no matching keys
+ * 		triggers a single out-of-band JWKS refresh before the TTL would normally expire. This
+ * 		eliminates the rotation window where an IdP publishes a new signing key mid-TTL and clients
+ * 		presenting tokens signed with that key would otherwise receive spurious 401 responses (up to
+ * 		the full TTL = 5 minutes by default). The eager refresh is bounded by two guards:
+ * 		<ul>
+ * 			<li>A <em>per-cache cooldown</em> (default 10 seconds) that caps how often the JWKS
+ * 				endpoint can be hit during a miss burst or an endpoint outage.
+ * 			<li>A <em>shared in-flight {@link CompletableFuture}</em> so at most one network request
+ * 				runs concurrently; all other threads experiencing the same miss wait for (and share)
+ * 				the result of that single fetch.
+ * 		</ul>
+ * 	<li>This feature applies only when the cache is constructed via
+ * 		{@link JwtTokenValidator.Builder#jwksUrl(java.net.URI) jwksUrl(...)}. A caller-supplied
+ * 		{@link JwtTokenValidator.Builder#jwkSource(JWKSource) jwkSource(...)} is unaffected.
  * </ul>
  *
  * <p>
@@ -61,25 +79,51 @@ final class JwksCache implements JWKSource<SecurityContext> {
 	/** Selector that pulls every key from the upstream source. */
 	private static final JWKSelector SELECT_ALL = new JWKSelector(new JWKMatcher.Builder().build());
 
+	/**
+	 * Maximum wait for a loser thread joining an in-flight eager refresh.  Slightly longer than a
+	 * typical HTTP read timeout so a misbehaving delegate can't pin a request thread indefinitely.
+	 */
+	private static final long IN_FLIGHT_TIMEOUT_SECONDS = 10;
+
 	private final JWKSource<SecurityContext> delegate;
 	private final Duration ttl;
 	private final Clock clock;
+	private final boolean eagerRefreshOnKidMiss;
+	private final Duration eagerRefreshCooldown;
 	private final AtomicReference<Entry> slot = new AtomicReference<>();
 
-	JwksCache(JWKSource<SecurityContext> delegate, Duration ttl, Clock clock) {
+	/** Epoch of the most recent eager-refresh attempt; {@link Instant#MIN} initially (first attempt always allowed). */
+	private final AtomicReference<Instant> lastEagerAttempt = new AtomicReference<>(Instant.MIN);
+
+	/**
+	 * In-flight eager-refresh future; {@code null} when idle.  Set via CAS to deduplicate concurrent
+	 * miss requests: the CAS winner does the single network fetch; losers await this future.
+	 */
+	private final AtomicReference<CompletableFuture<JWKSet>> inFlight = new AtomicReference<>();
+
+	JwksCache(JWKSource<SecurityContext> delegate, Duration ttl, Clock clock,
+			boolean eagerRefreshOnKidMiss, Duration eagerRefreshCooldown) {
 		this.delegate = delegate;
 		this.ttl = ttl;
 		this.clock = clock;
+		this.eagerRefreshOnKidMiss = eagerRefreshOnKidMiss;
+		this.eagerRefreshCooldown = eagerRefreshCooldown;
 	}
 
 	@Override /* Overridden from JWKSource */
 	public List<JWK> get(JWKSelector selector, SecurityContext ctx) throws KeySourceException {
 		var now = clock.instant();
 		var current = slot.get();
-		if (current != null && current.fetchedAt.plus(ttl).isAfter(now))
-			return selector.select(current.jwkSet);
+		if (current != null && current.fetchedAt.plus(ttl).isAfter(now)) {
+			// Fresh-cache path: serve from cache unless we get an empty selection and eager is on.
+			var hits = selector.select(current.jwkSet);
+			if (!hits.isEmpty() || !eagerRefreshOnKidMiss)
+				return hits;
+			return tryEagerRefresh(selector, ctx, now);
+		}
+		// Expired / cold path — unchanged from pre-9.5.0 behavior.
 		try {
-			var refreshed = new JWKSet(delegate.get(SELECT_ALL, ctx));
+			var refreshed = fetchFromDelegate(ctx);
 			slot.set(new Entry(refreshed, now));
 			return selector.select(refreshed);
 		} catch (KeySourceException | RuntimeException e) {
@@ -92,5 +136,64 @@ final class JwksCache implements JWKSource<SecurityContext> {
 		}
 	}
 
+	/**
+	 * Attempts a single eager refresh when the fresh cache produced no keys for the selector.
+	 *
+	 * <ul>
+	 * 	<li>Cooldown gate: returns empty immediately if a prior attempt was within the cooldown window.
+	 * 	<li>CAS winner: runs the one network fetch, updates the slot, completes the shared future.
+	 * 	<li>CAS losers: wait for the winner's future (bounded timeout), then select from its result.
+	 * 	<li>On outage: retains the last-known-good slot, logs WARNING, returns empty (→ 401).
+	 * 	<li>{@code lastEagerAttempt} is always advanced in the {@code finally} block so a burst of
+	 * 		distinct unknown kids cannot bypass the cooldown.
+	 * </ul>
+	 */
+	private List<JWK> tryEagerRefresh(JWKSelector selector, SecurityContext ctx, Instant now) {
+		if (now.isBefore(lastEagerAttempt.get().plus(eagerRefreshCooldown))) {
+			LOG.fine(() -> "JWKS eager refresh skipped (within cooldown)");
+			return Collections.emptyList();
+		}
+		var myFuture = new CompletableFuture<JWKSet>();
+		var prior = inFlight.compareAndExchange(null, myFuture);
+		if (prior != null)
+			return awaitAndSelect(selector, prior);
+
+		// CAS winner: run the single eager fetch.
+		try {
+			LOG.fine(() -> "JWKS eager refresh triggered by kid miss");
+			var refreshed = fetchFromDelegate(ctx);
+			slot.set(new Entry(refreshed, now));
+			myFuture.complete(refreshed);
+			return selector.select(refreshed);
+		} catch (KeySourceException | RuntimeException e) {
+			myFuture.completeExceptionally(e);
+			if (slot.get() != null)
+				LOG.log(Level.WARNING, e,
+					() -> "JWKS eager refresh failed; retaining last-known-good set.  Underlying error: " + e.getMessage());
+			return Collections.emptyList();
+		} finally {
+			// Advance cooldown on every attempt (success, still-missing, or outage) so a burst of
+			// unknown kids cannot hammer the endpoint within one cooldown window.
+			lastEagerAttempt.set(now);
+			inFlight.compareAndSet(myFuture, null);
+		}
+	}
+
+	/** Loser path: wait for the in-flight future and select from its result. */
+	private static List<JWK> awaitAndSelect(JWKSelector selector, CompletableFuture<JWKSet> future) {
+		try {
+			return selector.select(future.get(IN_FLIGHT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+			return Collections.emptyList();
+		} catch (ExecutionException | TimeoutException e) {
+			return Collections.emptyList();
+		}
+	}
+
+	private JWKSet fetchFromDelegate(SecurityContext ctx) throws KeySourceException {
+		return new JWKSet(delegate.get(SELECT_ALL, ctx));
+	}
+
 	private record Entry(JWKSet jwkSet, Instant fetchedAt) {}
 }
diff --git a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
index 6f304c09cb..53c66f5ae3 100644
--- a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
+++ b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
@@ -53,7 +53,15 @@ import com.nimbusds.jwt.proc.*;
  * 	<li><b>Clock skew</b> — defaults to 60 seconds tolerance for {@code exp} and {@code nbf}.
  * 		Capped at 300 seconds by the builder.
  * 	<li><b>JWKS caching</b> — keys are fetched and cached for 5 minutes by default. On fetch failure
- * 		the cache continues serving past TTL with a warning logged (see {@link JwksCache}).
+ * 		the cache continues serving past TTL with a warning logged (see {@link JwksCache}). When a
+ * 		fresh-cache selection returns no keys (typically because an IdP rotated its signing key
+ * 		mid-TTL), the cache performs one eager out-of-band refresh before failing the request. This
+ * 		behavior is <b>enabled by default</b> ({@link Builder#jwksEagerRefreshOnKidMiss(boolean)
+ * 		jwksEagerRefreshOnKidMiss(true)}) and bounded by a per-cache cooldown (default 10 seconds)
+ * 		plus a single-in-flight guard. Opt out via
+ * 		{@link Builder#jwksEagerRefreshOnKidMiss(boolean) jwksEagerRefreshOnKidMiss(false)}.
+ * 		Applies only to {@link Builder#jwksUrl(java.net.URI) jwksUrl(...)}-backed caches; a
+ * 		caller-supplied {@link Builder#jwkSource(JWKSource) jwkSource(...)} is unaffected.
  * </ul>
  *
  * <h5 class='topic'>Usage</h5>
@@ -141,6 +149,22 @@ public class JwtTokenValidator implements TokenValidator {
 		@Value("${juneau.jwt.jwksCacheTtl:PT5M}")
 		Duration jwksCacheTtl;
 
+		/**
+		 * Whether to perform an eager JWKS refresh on a {@code kid} cache miss; populated via
+		 * {@link Value @Value} from {@code juneau.jwt.jwksEagerRefreshOnKidMiss} (boolean, default
+		 * {@code true}).
+		 */
+		@Value("${juneau.jwt.jwksEagerRefreshOnKidMiss:true}")
+		boolean jwksEagerRefreshOnKidMiss;
+
+		/**
+		 * Minimum interval between eager JWKS refreshes; populated via {@link Value @Value} from
+		 * {@code juneau.jwt.jwksEagerRefreshCooldown} (ISO-8601 duration, default {@code PT10S} = 10
+		 * seconds).
+		 */
+		@Value("${juneau.jwt.jwksEagerRefreshCooldown:PT10S}")
+		Duration jwksEagerRefreshCooldown;
+
 		private Clock clock = Clock.systemUTC();
 
 		/** Constructor. */
@@ -255,6 +279,55 @@ public class JwtTokenValidator implements TokenValidator {
 			return this;
 		}
 
+		/**
+		 * Enables or disables the eager JWKS refresh that fires on a fresh-cache {@code kid} miss.
+		 *
+		 * <p>
+		 * When {@code true} (the default), the first request that presents a token whose signing
+		 * key is not in the current (still-fresh) cache triggers a single out-of-band JWKS refresh.
+		 * This closes the rotation window where an IdP publishes a new key mid-TTL and clients would
+		 * otherwise see up to {@code jwksCacheTtl} minutes of spurious 401 responses for perfectly
+		 * valid tokens. The refresh is bounded by {@link #jwksEagerRefreshCooldown(Duration)} and a
+		 * single-in-flight guard (no thundering herd).
+		 *
+		 * <p>
+		 * Applies only to {@link #jwksUrl(java.net.URI) jwksUrl(...)}-backed caches. A
+		 * caller-supplied {@link #jwkSource(JWKSource) jwkSource(...)} is unaffected.
+		 *
+		 * @param value {@code true} to enable (default); {@code false} to restore pre-9.5.0 behavior.
+		 * @return This object.
+		 */
+		public Builder jwksEagerRefreshOnKidMiss(boolean value) {
+			jwksEagerRefreshOnKidMiss = value;
+			return this;
+		}
+
+		/**
+		 * Sets the minimum spacing between consecutive eager JWKS refreshes.
+		 *
+		 * <p>
+		 * A burst of unknown-{@code kid} tokens (e.g. misconfigured client, replay attack) or a
+		 * JWKS-endpoint outage will trigger at most one eager refresh per cooldown window. This
+		 * prevents the eager-refresh path from becoming a vector for amplified JWKS-endpoint load.
+		 *
+		 * <p>
+		 * Must be positive, at most 60 seconds, and at most {@link #jwksCacheTtl(Duration)
+		 * jwksCacheTtl} (validated at {@link #build()} time).
+		 *
+		 * @param value The cooldown. Must not be {@code null}.
+		 * @return This object.
+		 */
+		public Builder jwksEagerRefreshCooldown(Duration value) {
+			assertArgNotNull("value", value);
+			if (value.isZero() || value.isNegative())
+				throw new IllegalArgumentException("jwksEagerRefreshCooldown must be positive");
+			if (value.compareTo(Duration.ofSeconds(60)) > 0)
+				throw new IllegalArgumentException(
+					"jwksEagerRefreshCooldown must not exceed 60 seconds (was " + value + ")");
+			jwksEagerRefreshCooldown = value;
+			return this;
+		}
+
 		/**
 		 * Overrides the {@link Clock} used for {@code exp} / {@code nbf} comparisons and JWKS cache
 		 * TTL. Useful in tests.
@@ -281,6 +354,10 @@ public class JwtTokenValidator implements TokenValidator {
 				throw new IllegalStateException("JwtTokenValidator requires either jwksUrl(...) or jwkSource(...)");
 			if (jwksUrl != null && jwkSource != null)
 				throw new IllegalStateException("JwtTokenValidator: jwksUrl(...) and jwkSource(...) are mutually exclusive");
+			if (jwksEagerRefreshCooldown.compareTo(jwksCacheTtl) > 0)
+				throw new IllegalStateException(
+					"jwksEagerRefreshCooldown (" + jwksEagerRefreshCooldown
+					+ ") must not exceed jwksCacheTtl (" + jwksCacheTtl + ")");
 			return new JwtTokenValidator(this);
 		}
 	}
@@ -305,7 +382,8 @@ public class JwtTokenValidator implements TokenValidator {
 		this.clock = builder.clock;
 		this.jwkSource = builder.jwkSource != null
 			? builder.jwkSource
-			: new JwksCache(createRemoteJwkSource(builder.jwksUrl), builder.jwksCacheTtl, builder.clock);
+			: new JwksCache(createRemoteJwkSource(builder.jwksUrl), builder.jwksCacheTtl, builder.clock,
+				builder.jwksEagerRefreshOnKidMiss, builder.jwksEagerRefreshCooldown);
 	}
 
 	/**
diff --git a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
index 4ab8d65922..f05e88e7da 100644
--- a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
@@ -82,6 +82,12 @@ import io.micrometer.core.instrument.*;
  * 	<jk>new</jk> MicrometerMetricsRecorder(<jv>r</jv>, <js>"my.service.http.timer"</js>)
  * </p>
  *
+ * <p>
+ * Per-operation overrides are available via {@link org.apache.juneau.rest.annotation.RestOp#metricName()}.
+ * When a non-empty {@code metricName} is passed to {@code record(...)}, it takes precedence over the
+ * recorder-level timer name. Per-op tags added via {@link org.apache.juneau.rest.annotation.RestOp#metricTags()}
+ * are merged into the standard tag set as additional {@code key=value} pairs.
+ *
  * <h5 class='section'>See Also:</h5><ul>
  * 	<li class='jc'>{@link MetricsRecorder}
  * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
@@ -144,14 +150,20 @@ public class MicrometerMetricsRecorder implements MetricsRecorder {
 	public String getTimerName() { return timerName; }
 
 	@Override /* MetricsRecorder */
-	public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
-		Timer.builder(timerName)
+	public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+		var effectiveName = (metricName != null && !metricName.isEmpty()) ? metricName : timerName;
+		var builder = Timer.builder(effectiveName)
 			.tag(TAG_METHOD, defaultIfBlank(httpMethod, ""))
 			.tag(TAG_URI, defaultIfBlank(uriTemplate, ""))
 			.tag(TAG_STATUS, Integer.toString(statusCode))
-			.tag(TAG_EXCEPTION, exceptionTag(error))
-			.register(registry)
-			.record(elapsed);
+			.tag(TAG_EXCEPTION, exceptionTag(error));
+		if (metricTags != null && !metricTags.isEmpty())
+			for (var pair : metricTags.split(",")) {
+				var kv = pair.split("=", 2);
+				if (kv.length == 2)
+					builder = builder.tag(kv[0].strip(), kv[1].strip());
+			}
+		builder.register(registry).record(elapsed);
 	}
 
 	private static String exceptionTag(Throwable error) {
diff --git a/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java b/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
index 63307f0244..78fca82722 100644
--- a/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
+++ b/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
@@ -32,6 +32,8 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest;
 import com.nimbusds.oauth2.sdk.id.ClientID;
 import com.nimbusds.oauth2.sdk.id.State;
 import com.nimbusds.oauth2.sdk.pkce.*;
+import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
+import com.nimbusds.openid.connect.sdk.Nonce;
 
 /**
  * Authorization-code grant (RFC 6749 &sect;4.1) flow helper with mandatory PKCE per RFC 7636.
@@ -249,6 +251,45 @@ public class OAuthAuthorizationCodeFlow {
 		return request.toURI();
 	}
 
+	/**
+	 * Builds an OpenID Connect {@code authorize?...} URL carrying an OIDC {@code nonce} parameter.
+	 *
+	 * <p>
+	 * Unlike {@link #buildAuthorizationUrl(String, CodeChallenge)} (which emits a plain OAuth 2.0
+	 * {@link AuthorizationRequest} with no {@code nonce}), this method emits an OIDC
+	 * {@link AuthenticationRequest} so the IdP echoes the supplied {@code nonce} back inside the issued
+	 * ID token.  An OpenID Connect relying party stores the {@code nonce} before the redirect and
+	 * verifies the {@code nonce} claim on the returned ID token to defeat token replay.
+	 *
+	 * <p>
+	 * The configured {@code scope(...)} set is used as-is; OIDC requires it to contain {@code openid}.
+	 * The optional {@code customizer} receives the underlying {@link AuthenticationRequest.Builder} so
+	 * callers can set OIDC-specific parameters (e.g. {@code prompt}, {@code max_age}, {@code acr_values})
+	 * without this module needing to surface each one.
+	 *
+	 * @param state The opaque {@code state} parameter (CSRF protection).  Must not be <jk>null</jk> or
+	 * 	blank.
+	 * @param codeChallenge The PKCE code challenge.  Must not be <jk>null</jk>.  Always uses S256.
+	 * @param nonce The OIDC {@code nonce} value.  Must not be <jk>null</jk> or blank.
+	 * @param customizer Optional hook on the {@link AuthenticationRequest.Builder} for OIDC-specific
+	 * 	parameters.  May be <jk>null</jk>.
+	 * @return The authorization URL.
+	 */
+	public URI buildAuthenticationUrl(String state, CodeChallenge codeChallenge, String nonce, Consumer<AuthenticationRequest.Builder> customizer) {
+		assertArgNotNullOrBlank("state", state);
+		assertArgNotNull("codeChallenge", codeChallenge);
+		assertArgNotNullOrBlank("nonce", nonce);
+		Scope nimbusScope = scopes.isEmpty() ? new Scope("openid") : new Scope(scopes.toArray(new String[0]));
+		var builder = new AuthenticationRequest.Builder(new ResponseType(ResponseType.Value.CODE), nimbusScope, new ClientID(clientId), redirectUri)
+			.endpointURI(authorizationEndpoint)
+			.state(new State(state))
+			.nonce(new Nonce(nonce))
+			.codeChallenge(codeChallenge, CodeChallengeMethod.S256);
+		if (customizer != null)
+			customizer.accept(builder);
+		return builder.build().toURI();
+	}
+
 	/**
 	 * Exchanges the authorization code returned by the IdP for an access token.
 	 *
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/pom.xml b/juneau-rest/juneau-rest-server-oidc-rp/pom.xml
new file mode 100644
index 0000000000..00d879d3fe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/pom.xml
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+	<modelVersion>4.0.0</modelVersion>
+
+	<parent>
+		<groupId>org.apache.juneau</groupId>
+		<artifactId>juneau-rest</artifactId>
+		<version>9.5.0-SNAPSHOT</version>
+	</parent>
+
+	<artifactId>juneau-rest-server-oidc-rp</artifactId>
+	<name>Apache Juneau REST Server OIDC Relying Party</name>
+	<description>Apache Juneau REST Server - OpenID Connect Relying Party login (opt-in, Nimbus SDK)</description>
+	<packaging>bundle</packaging>
+
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<!--
+			Nimbus OAuth 2.0 SDK pin - kept on the same 11.37.2 line as juneau-rest-server-oauth
+			(see FINISHED-94c).  A future bump must re-run the dependency:tree containment check.
+		-->
+		<nimbus-oauth2-oidc-sdk.version>11.37.2</nimbus-oauth2-oidc-sdk.version>
+	</properties>
+
+	<dependencies>
+		<!--
+			Compile dependency on the OAuth building-block module (FINISHED-94c): OidcDiscoveryClient,
+			OAuthAuthorizationCodeFlow, OAuthRefreshTokenFlow, OAuthToken.  This transitively brings
+			juneau-rest-server (the AuthFilter framework + ClaimsPrincipal) onto the classpath.
+		-->
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-server-oauth</artifactId>
+			<version>${project.version}</version>
+		</dependency>
+
+		<!--
+			Nimbus OAuth 2.0 SDK is declared `provided` so it does NOT leak as a transitive dependency
+			of this module or any upstream module.  Mirrors the FINISHED-94c containment pattern - the
+			containment check `mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -iE
+			"(nimbusds|oauth2-oidc)"` must return nothing.
+
+			Brings nimbus-jose-jwt transitively (also contained), which this module uses for the
+			ID-token / logout-token signature handling via Nimbus's validators.
+		-->
+		<dependency>
+			<groupId>com.nimbusds</groupId>
+			<artifactId>oauth2-oidc-sdk</artifactId>
+			<version>${nimbus-oauth2-oidc-sdk.version}</version>
+			<scope>provided</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-source-plugin</artifactId>
+				<executions>
+					<execution>
+						<id>attach-sources</id>
+						<phase>verify</phase>
+						<goals>
+							<goal>jar-no-fork</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.felix</groupId>
+				<artifactId>maven-bundle-plugin</artifactId>
+				<extensions>true</extensions>
+				<configuration>
+					<supportIncrementalBuild>true</supportIncrementalBuild>
+				</configuration>
+				<executions>
+					<execution>
+						<id>bundle-manifest</id>
+						<phase>process-classes</phase>
+						<goals>
+							<goal>manifest</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+			</plugin>
+			<plugin>
+				<groupId>org.jacoco</groupId>
+				<artifactId>jacoco-maven-plugin</artifactId>
+				<executions>
+					<execution>
+						<id>default-prepare-agent</id>
+						<goals>
+							<goal>prepare-agent</goal>
+						</goals>
+					</execution>
+					<execution>
+						<id>default-report</id>
+						<phase>prepare-package</phase>
+						<goals>
+							<goal>report</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java
new file mode 100644
index 0000000000..1e88198364
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java
@@ -0,0 +1,154 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * Single-use, TTL-bounded store for the per-login {@code state} &rarr; {@code (nonce, codeVerifier,
+ * redirectTarget)} association created during {@link OidcRelyingParty#startLogin} and consumed during
+ * {@link OidcRelyingParty#completeLogin}.
+ *
+ * <p>
+ * Security properties (see OpenID Connect Core &sect;3.1.2.1 and the RP charter):
+ * <ul>
+ * 	<li><b>Single-use</b> &mdash; {@link #consume(String)} atomically removes the entry, so a replayed
+ * 		callback with the same {@code state} fails.
+ * 	<li><b>TTL-bounded</b> &mdash; entries older than the configured TTL are treated as a miss and swept.
+ * 	<li><b>Size-bounded</b> &mdash; an LRU cap (default 10 000) prevents unbounded growth from abandoned
+ * 		login attempts; eviction mirrors the {@code BoundedLruTokenCache} shape from FINISHED-94c.
+ * </ul>
+ *
+ * <p>
+ * Thread-safe.
+ *
+ * @since 9.5.0
+ */
+public class EphemeralStore {
+
+	/** Default maximum number of in-flight login attempts retained. */
+	public static final int DEFAULT_MAX_ENTRIES = 10_000;
+
+	/** Default time-to-live for a pending login (the user has at most a few minutes at the IdP). */
+	public static final Duration DEFAULT_TTL = Duration.ofMinutes(5);
+
+	/** Hard cap on the configurable TTL. */
+	static final Duration MAX_TTL = Duration.ofMinutes(30);
+
+	/**
+	 * A pending-login association awaiting the IdP callback.
+	 *
+	 * @param nonce The OIDC {@code nonce} value bound into the authorization request.
+	 * @param codeVerifier The PKCE {@code code_verifier} string.
+	 * @param redirectTarget The application URL to redirect to after a successful login.  May be
+	 * 	{@code null}.
+	 * @param createdAt The instant the entry was stored.
+	 */
+	public record PendingLogin(String nonce, String codeVerifier, String redirectTarget, Instant createdAt) {}
+
+	private final int maxEntries;
+	private final Duration ttl;
+	private final Clock clock;
+	private final Map<String,PendingLogin> entries;
+	private final Object lock = new Object();
+
+	/**
+	 * Constructor.
+	 *
+	 * @param ttl The single-use TTL.  Must be positive and not exceed 30 minutes.
+	 * @param maxEntries The LRU size cap.  Must be positive.
+	 * @param clock The clock for TTL comparisons.  Must not be <jk>null</jk>.
+	 */
+	public EphemeralStore(Duration ttl, int maxEntries, Clock clock) {
+		assertArgNotNull("ttl", ttl);
+		assertArg(!ttl.isZero() && !ttl.isNegative(), "ttl must be positive");
+		assertArg(ttl.compareTo(MAX_TTL) <= 0, "ttl must not exceed 30 minutes (was {0})", ttl);
+		assertArg(maxEntries > 0, "maxEntries must be positive (was {0})", maxEntries);
+		this.ttl = ttl;
+		this.maxEntries = maxEntries;
+		this.clock = assertArgNotNull("clock", clock);
+		this.entries = new LinkedHashMap<>(16, 0.75f, true) {
+			private static final long serialVersionUID = 1L;
+			@Override
+			protected boolean removeEldestEntry(Map.Entry<String,PendingLogin> eldest) {
+				return size() > EphemeralStore.this.maxEntries;
+			}
+		};
+	}
+
+	/**
+	 * Stores a pending-login association keyed by {@code state}.
+	 *
+	 * @param state The opaque {@code state} value.  Must not be <jk>null</jk> or blank.
+	 * @param nonce The OIDC {@code nonce}.  Must not be <jk>null</jk> or blank.
+	 * @param codeVerifier The PKCE {@code code_verifier}.  Must not be <jk>null</jk> or blank.
+	 * @param redirectTarget The post-login redirect target.  May be <jk>null</jk>.
+	 */
+	public void store(String state, String nonce, String codeVerifier, String redirectTarget) {
+		assertArgNotNullOrBlank("state", state);
+		assertArgNotNullOrBlank("nonce", nonce);
+		assertArgNotNullOrBlank("codeVerifier", codeVerifier);
+		var now = clock.instant();
+		synchronized (lock) {
+			sweepExpired(now);
+			entries.put(state, new PendingLogin(nonce, codeVerifier, redirectTarget, now));
+		}
+	}
+
+	/**
+	 * Atomically removes and returns the pending-login association for {@code state}, if present and
+	 * not expired.
+	 *
+	 * @param state The {@code state} value from the callback.  Must not be <jk>null</jk>.
+	 * @return The association, or {@link Optional#empty()} if absent or expired.
+	 */
+	public Optional<PendingLogin> consume(String state) {
+		if (state == null)
+			return Optional.empty();
+		var now = clock.instant();
+		synchronized (lock) {
+			var p = entries.remove(state);
+			if (p == null)
+				return Optional.empty();
+			if (isExpired(p, now))
+				return Optional.empty();
+			return Optional.of(p);
+		}
+	}
+
+	/**
+	 * Returns the current entry count.  Primarily for tests and metrics.
+	 *
+	 * @return The entry count.
+	 */
+	public int size() {
+		synchronized (lock) {
+			return entries.size();
+		}
+	}
+
+	private boolean isExpired(PendingLogin p, Instant now) {
+		return !now.isBefore(p.createdAt().plus(ttl));
+	}
+
+	private void sweepExpired(Instant now) {
+		entries.values().removeIf(p -> isExpired(p, now));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java
new file mode 100644
index 0000000000..d5517a1d85
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java
@@ -0,0 +1,253 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.net.*;
+import java.text.ParseException;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.jwk.*;
+import com.nimbusds.jose.jwk.source.*;
+import com.nimbusds.jose.proc.*;
+import com.nimbusds.jwt.*;
+import com.nimbusds.oauth2.sdk.id.*;
+import com.nimbusds.openid.connect.sdk.Nonce;
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
+
+/**
+ * Wraps Nimbus's {@link IDTokenValidator} to validate an OpenID Connect ID token's signature and claims
+ * and project the verified claims onto a Juneau {@link ClaimsPrincipal}.
+ *
+ * <p>
+ * Nimbus's {@code IDTokenValidator} performs the full OIDC ID-token validation in one call: signature
+ * verification against the IdP JWKS, exact {@code iss} match, {@code aud} contains the client id,
+ * {@code azp} when there are multiple audiences, {@code exp} (with clock skew), required {@code iat},
+ * and &mdash; when supplied &mdash; {@code nonce} match.  This adapter owns only the configuration and
+ * the projection to {@link ClaimsPrincipal}; no bespoke crypto.
+ *
+ * <p>
+ * The signing-algorithm allowlist defaults to {@code [RS256, ES256]} (both SHA-256 based).  The unsafe
+ * {@code "none"} algorithm is permanently rejected (a token signed with {@code none} has no key and so
+ * fails the key selector), and SHA-1-family algorithms are excluded by virtue of not being on the
+ * allowlist &mdash; consistent with the {@code juneau-rest-server-jwt} strict-default stance.
+ *
+ * <p>
+ * For production, configure {@link Builder#jwksUri(URI)} (Nimbus builds a cached remote key source).
+ * For tests / HSM-backed signers / multi-issuer setups, inject a {@link Builder#jwkSet(JWKSet)} or
+ * {@link Builder#jwkSource(JWKSource)} directly.
+ *
+ * @since 9.5.0
+ */
+public class IdTokenValidatorAdapter {
+
+	/**
+	 * Static creator.
+	 *
+	 * @return A new builder.
+	 */
+	public static Builder create() {
+		return new Builder();
+	}
+
+	/**
+	 * Builder.
+	 */
+	public static class Builder {
+		private String issuer;
+		private String clientId;
+		private URI jwksUri;
+		private JWKSet jwkSet;
+		private JWKSource<SecurityContext> jwkSource;
+		private Set<JWSAlgorithm> algorithms = new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256));
+		private int maxClockSkewSeconds = 60;
+
+		/** Constructor. */
+		protected Builder() {}
+
+		/**
+		 * Sets the expected {@code iss} (issuer).  Required.
+		 *
+		 * @param value The issuer.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder issuer(String value) {
+			issuer = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the OAuth client id (expected {@code aud}).  Required.
+		 *
+		 * @param value The client id.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder clientId(String value) {
+			clientId = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the JWKS endpoint URI the validator fetches signing keys from.  Mutually exclusive with
+		 * {@link #jwkSet(JWKSet)} / {@link #jwkSource(JWKSource)}.
+		 *
+		 * @param value The JWKS URI.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder jwksUri(URI value) {
+			jwksUri = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets a fixed JWK set (useful for tests / static keys).  Mutually exclusive with
+		 * {@link #jwksUri(URI)} / {@link #jwkSource(JWKSource)}.
+		 *
+		 * @param value The JWK set.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder jwkSet(JWKSet value) {
+			jwkSet = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets a custom {@link JWKSource}.  Mutually exclusive with {@link #jwksUri(URI)} /
+		 * {@link #jwkSet(JWKSet)}.
+		 *
+		 * @param value The JWK source.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder jwkSource(JWKSource<SecurityContext> value) {
+			jwkSource = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the signing-algorithm allowlist.  Replaces the default {@code [RS256, ES256]}.
+		 *
+		 * @param values The allowed algorithms.  Must be non-empty and must not contain
+		 * 	{@link JWSAlgorithm#NONE}.
+		 * @return This object.
+		 */
+		public Builder algorithms(JWSAlgorithm...values) {
+			assertArgNotNull("values", values);
+			assertArg(values.length > 0, "algorithms allowlist must be non-empty");
+			var next = new LinkedHashSet<JWSAlgorithm>();
+			for (var a : values) {
+				assertArgNotNull("algorithm", a);
+				assertArg(! JWSAlgorithm.NONE.equals(a), "\"none\" algorithm is permanently rejected");
+				next.add(a);
+			}
+			algorithms = next;
+			return this;
+		}
+
+		/**
+		 * Sets the clock-skew tolerance (in seconds) for {@code exp} / {@code iat} validation.
+		 *
+		 * @param value The tolerance in seconds.  Must be non-negative.
+		 * @return This object.
+		 */
+		public Builder maxClockSkewSeconds(int value) {
+			assertArg(value >= 0, "maxClockSkewSeconds must be non-negative (was {0})", value);
+			maxClockSkewSeconds = value;
+			return this;
+		}
+
+		/**
+		 * Builds the adapter.
+		 *
+		 * @return A new {@link IdTokenValidatorAdapter}.
+		 */
+		public IdTokenValidatorAdapter build() {
+			if (issuer == null)
+				throw new IllegalStateException("IdTokenValidatorAdapter requires issuer(...)");
+			if (clientId == null)
+				throw new IllegalStateException("IdTokenValidatorAdapter requires clientId(...)");
+			var sources = 0;
+			if (jwksUri != null) sources++;
+			if (jwkSet != null) sources++;
+			if (jwkSource != null) sources++;
+			if (sources != 1)
+				throw new IllegalStateException("IdTokenValidatorAdapter requires exactly one of jwksUri(...), jwkSet(...), or jwkSource(...)");
+			return new IdTokenValidatorAdapter(this);
+		}
+	}
+
+	private final IDTokenValidator validator;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param b The builder.
+	 */
+	protected IdTokenValidatorAdapter(Builder b) {
+		JWKSource<SecurityContext> source = resolveSource(b);
+		var keySelector = new JWSVerificationKeySelector<SecurityContext>(b.algorithms, source);
+		this.validator = new IDTokenValidator(new Issuer(b.issuer), new ClientID(b.clientId), keySelector, null);
+		this.validator.setMaxClockSkew(b.maxClockSkewSeconds);
+	}
+
+	private static JWKSource<SecurityContext> resolveSource(Builder b) {
+		if (b.jwkSource != null)
+			return b.jwkSource;
+		if (b.jwkSet != null)
+			return new ImmutableJWKSet<>(b.jwkSet);
+		try {
+			return JWKSourceBuilder.create(b.jwksUri.toURL()).build();
+		} catch (MalformedURLException e) {
+			throw new IllegalArgumentException("Invalid JWKS URI: " + b.jwksUri, e);
+		}
+	}
+
+	/**
+	 * Validates an ID token and projects the verified claims onto a {@link ClaimsPrincipal}.
+	 *
+	 * @param idToken The raw (compact) ID token JWT.  Must not be <jk>null</jk> or blank.
+	 * @param expectedNonce The {@code nonce} the relying party stored before the redirect; the ID
+	 * 	token's {@code nonce} claim must match.  May be <jk>null</jk> only if the flow did not use a
+	 * 	nonce (the RP always supplies one).
+	 * @return The verified claims as a {@link ClaimsPrincipal} whose name is the {@code sub} claim.
+	 * @throws AuthenticationException If parsing, signature verification, or any claim check fails.
+	 */
+	public ClaimsPrincipal validate(String idToken, String expectedNonce) throws AuthenticationException {
+		assertArgNotNullOrBlank("idToken", idToken);
+		JWT jwt;
+		try {
+			jwt = JWTParser.parse(idToken);
+		} catch (ParseException e) {
+			throw new AuthenticationException(e, "ID token could not be parsed");
+		}
+		IDTokenClaimsSet claims;
+		try {
+			claims = validator.validate(jwt, expectedNonce == null ? null : new Nonce(expectedNonce));
+		} catch (BadJOSEException e) {
+			throw new AuthenticationException(e, "ID token validation failed: {0}", e.getMessage());
+		} catch (JOSEException e) {
+			throw new AuthenticationException(e, "ID token signature verification failed");
+		}
+		var sub = claims.getSubject().getValue();
+		return new ClaimsPrincipal(sub, claims.toJSONObject());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java
new file mode 100644
index 0000000000..3eac9c9764
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java
@@ -0,0 +1,198 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * In-memory {@link SessionStore} backed by a bounded-LRU map, indexed by {@code sub} and {@code sid} so
+ * it can satisfy back-channel logout.
+ *
+ * <p>
+ * This is the simpler single-instance / development option.  It is lost on restart and breaks under
+ * horizontal scaling without sticky sessions; for production multi-instance deployments use
+ * {@link SignedCookieSessionStore} (stateless, the documented default) or a caller-supplied distributed
+ * store.
+ *
+ * <p>
+ * Eviction is the conjunction of a size cap (LRU) and per-session TTL (an expired session is treated as
+ * a miss and removed on lookup).  Because it retains sessions server-side, it
+ * {@link #supportsServerSideRevocation() supports} {@link #invalidateBySubject(String)} and
+ * {@link #invalidateBySessionId(String)}.
+ *
+ * @since 9.5.0
+ */
+public class InMemorySessionStore implements SessionStore {
+
+	/** Default maximum number of live sessions retained. */
+	public static final int DEFAULT_MAX_ENTRIES = 10_000;
+
+	/**
+	 * Static creator with the default size cap.
+	 *
+	 * @return A new store.
+	 */
+	public static InMemorySessionStore create() {
+		return new InMemorySessionStore(DEFAULT_MAX_ENTRIES, Clock.systemUTC());
+	}
+
+	/**
+	 * Static creator with a custom size cap.
+	 *
+	 * @param maxEntries The maximum number of live sessions.  Must be positive.
+	 * @return A new store.
+	 */
+	public static InMemorySessionStore create(int maxEntries) {
+		return new InMemorySessionStore(maxEntries, Clock.systemUTC());
+	}
+
+	private final int maxEntries;
+	private final Clock clock;
+	private final Map<String,OidcSession> byId;
+	private final Map<String,Set<String>> bySubject = new HashMap<>();
+	private final Map<String,Set<String>> bySid = new HashMap<>();
+	private final Object lock = new Object();
+
+	/**
+	 * Constructor.
+	 *
+	 * @param maxEntries The maximum number of live sessions.  Must be positive.
+	 * @param clock The clock used for TTL comparisons.  Must not be <jk>null</jk>.
+	 */
+	public InMemorySessionStore(int maxEntries, Clock clock) {
+		assertArg(maxEntries > 0, "maxEntries must be positive (was {0})", maxEntries);
+		this.maxEntries = maxEntries;
+		this.clock = assertArgNotNull("clock", clock);
+		this.byId = new LinkedHashMap<>(16, 0.75f, true) {
+			private static final long serialVersionUID = 1L;
+			@Override
+			protected boolean removeEldestEntry(Map.Entry<String,OidcSession> eldest) {
+				if (size() > InMemorySessionStore.this.maxEntries) {
+					unindex(eldest.getValue());
+					return true;
+				}
+				return false;
+			}
+		};
+	}
+
+	@Override /* Overridden from SessionStore */
+	public String createSessionCookieValue(OidcSession session) {
+		assertArgNotNull("session", session);
+		synchronized (lock) {
+			byId.put(session.id(), session);
+			bySubject.computeIfAbsent(session.subject(), k -> new HashSet<>()).add(session.id());
+			session.sid().ifPresent(sid -> bySid.computeIfAbsent(sid, k -> new HashSet<>()).add(session.id()));
+		}
+		return session.id();
+	}
+
+	@Override /* Overridden from SessionStore */
+	public Optional<OidcSession> lookup(String cookieValue) {
+		assertArgNotNull("cookieValue", cookieValue);
+		synchronized (lock) {
+			var s = byId.get(cookieValue);
+			if (s == null)
+				return Optional.empty();
+			if (s.isExpired(clock.instant())) {
+				removeById(cookieValue);
+				return Optional.empty();
+			}
+			return Optional.of(s);
+		}
+	}
+
+	@Override /* Overridden from SessionStore */
+	public void invalidate(String cookieValue) {
+		assertArgNotNull("cookieValue", cookieValue);
+		synchronized (lock) {
+			removeById(cookieValue);
+		}
+	}
+
+	@Override /* Overridden from SessionStore */
+	public boolean supportsServerSideRevocation() {
+		return true;
+	}
+
+	@Override /* Overridden from SessionStore */
+	public int invalidateBySubject(String subject) {
+		assertArgNotNullOrBlank("subject", subject);
+		synchronized (lock) {
+			var ids = bySubject.get(subject);
+			if (ids == null)
+				return 0;
+			var count = 0;
+			for (var id : new ArrayList<>(ids))
+				count += removeById(id) ? 1 : 0;
+			return count;
+		}
+	}
+
+	@Override /* Overridden from SessionStore */
+	public int invalidateBySessionId(String sid) {
+		assertArgNotNullOrBlank("sid", sid);
+		synchronized (lock) {
+			var ids = bySid.get(sid);
+			if (ids == null)
+				return 0;
+			var count = 0;
+			for (var id : new ArrayList<>(ids))
+				count += removeById(id) ? 1 : 0;
+			return count;
+		}
+	}
+
+	/**
+	 * Returns the current live-session count.  Primarily for tests and metrics.
+	 *
+	 * @return The session count.
+	 */
+	public int size() {
+		synchronized (lock) {
+			return byId.size();
+		}
+	}
+
+	private boolean removeById(String id) {
+		var removed = byId.remove(id);
+		if (removed == null)
+			return false;
+		unindex(removed);
+		return true;
+	}
+
+	private void unindex(OidcSession s) {
+		var subjects = bySubject.get(s.subject());
+		if (subjects != null) {
+			subjects.remove(s.id());
+			if (subjects.isEmpty())
+				bySubject.remove(s.subject());
+		}
+		s.sid().ifPresent(sid -> {
+			var sids = bySid.get(sid);
+			if (sids != null) {
+				sids.remove(s.id());
+				if (sids.isEmpty())
+					bySid.remove(sid);
+			}
+		});
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java
new file mode 100644
index 0000000000..f53a5bbd30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java
@@ -0,0 +1,1018 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.net.*;
+import java.security.Principal;
+import java.time.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.apache.juneau.rest.auth.oauth.OAuthToken;
+import org.apache.juneau.rest.auth.oauth.flow.*;
+import org.apache.juneau.rest.auth.oauth.oidc.*;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.*;
+import com.nimbusds.jose.proc.*;
+import com.nimbusds.jwt.*;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.*;
+import com.nimbusds.oauth2.sdk.pkce.*;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.*;
+import com.nimbusds.openid.connect.sdk.claims.LogoutTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.validators.LogoutTokenValidator;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Facade orchestrating an OpenID Connect Authorization-Code-with-PKCE <i>Relying Party</i> login flow on
+ * top of the {@code juneau-rest-server-oauth} building blocks.
+ *
+ * <p>
+ * A single instance wires the login / callback / logout endpoints plus the session SPI for one IdP.  The
+ * application mounts the three endpoints as explicit {@code @RestGet} methods that delegate to this
+ * facade &mdash; there are no auto-mounted servlet routes (URL ownership stays with the app):
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/auth"</js>)
+ * 	<jk>public class</jk> LoginResource <jk>extends</jk> BasicRestServlet {
+ *
+ * 		<jk>private final</jk> OidcRelyingParty <jf>rp</jf> = OidcRelyingParty.<jsm>create</jsm>()
+ * 			.issuer(URI.<jsm>create</jsm>(<js>"https://accounts.google.com"</js>))
+ * 			.clientId(<js>"web-app"</js>)
+ * 			.clientSecret(<jv>env</jv>(<js>"OIDC_CLIENT_SECRET"</js>))
+ * 			.redirectUri(URI.<jsm>create</jsm>(<js>"https://app.example.com/auth/callback"</js>))
+ * 			.scope(<js>"openid"</js>, <js>"profile"</js>, <js>"email"</js>)
+ * 			.sessionStore(SignedCookieSessionStore.<jsm>create</jsm>().signingKey(<jv>env</jv>(<js>"SESSION_KEY"</js>)).build())
+ * 			.build();
+ *
+ * 		<ja>@RestGet</ja>(path=<js>"/login"</js>)
+ * 		<jk>public void</jk> login(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * 			<jf>rp</jf>.startLogin(<jv>req</jv>, <jv>res</jv>);
+ * 		}
+ *
+ * 		<ja>@RestGet</ja>(path=<js>"/callback"</js>)
+ * 		<jk>public void</jk> callback(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * 			<jf>rp</jf>.completeLogin(<jv>req</jv>, <jv>res</jv>);
+ * 		}
+ *
+ * 		<ja>@RestGet</ja>(path=<js>"/logout"</js>)
+ * 		<jk>public void</jk> logout(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * 			<jf>rp</jf>.logout(<jv>req</jv>, <jv>res</jv>);
+ * 		}
+ * 	}
+ * </p>
+ *
+ * <p>
+ * Register {@link #authFilter()} in the servlet filter chain (or {@code AuthFilterChain}) so each request
+ * resolves the session cookie into a {@link ClaimsPrincipal} for {@code RoleBasedRestGuard} /
+ * {@code @Auth Principal}.
+ *
+ * <h5 class='section'>Security</h5>
+ * <ul>
+ * 	<li>{@code state} and {@code nonce} are single-use and TTL-bounded (default 5&nbsp;min); a
+ * 		missing / replayed value fails the callback.
+ * 	<li>PKCE S256 is enforced end-to-end.
+ * 	<li>The ID token is validated for signature, {@code iss}, {@code aud}/{@code azp}, {@code exp}, and
+ * 		{@code nonce} (see {@link IdTokenValidatorAdapter}).
+ * 	<li>The session id is freshly generated on login (no pre-auth fixation).
+ * 	<li>Auth responses set {@code Cache-Control: no-store}; tokens are never logged.
+ * 	<li>The session cookie is {@code HttpOnly} + {@code Secure} + {@code SameSite} by default.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link SessionStore}
+ * 	<li class='jc'>{@link OidcSessionAuthFilter}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * 	<li class='link'><a class="doclink" href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core 1.0</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class OidcRelyingParty {
+
+	private static final String DEFAULT_COOKIE_NAME = "JUNEAU_OIDC_SESSION";
+	private static final String DEFAULT_ROLES_CLAIM = "scope";
+	private static final Duration DEFAULT_SESSION_TTL = Duration.ofHours(8);
+
+	/**
+	 * Static creator.
+	 *
+	 * @return A new builder.
+	 */
+	public static Builder create() {
+		return new Builder();
+	}
+
+	/**
+	 * Builder.
+	 */
+	public static class Builder {
+		URI issuer;
+		OidcMetadata metadata;
+		String clientId;
+		Supplier<String> clientSecretSupplier;
+		URI redirectUri;
+		URI postLogoutRedirectUri;
+		String postLoginRedirect = "/";
+		Set<String> scopes = new LinkedHashSet<>();
+		SessionStore sessionStore;
+		String rolesClaim = DEFAULT_ROLES_CLAIM;
+		Duration stateNonceTtl = EphemeralStore.DEFAULT_TTL;
+		int ephemeralMaxEntries = EphemeralStore.DEFAULT_MAX_ENTRIES;
+		Duration sessionTtl = DEFAULT_SESSION_TTL;
+		String cookieName = DEFAULT_COOKIE_NAME;
+		boolean cookieSecure = true;
+		boolean cookieHttpOnly = true;
+		String cookieSameSite = "Lax";
+		Consumer<HTTPRequest> httpRequestConfigurator;
+		Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer;
+		JWSAlgorithm[] idTokenAlgorithms;
+		int clockSkewSeconds = 60;
+		JWKSet jwkSet;
+		JWKSource<SecurityContext> jwkSource;
+		Set<String> userInfoClaims = new LinkedHashSet<>();
+		Clock clock = Clock.systemUTC();
+
+		/** Constructor. */
+		protected Builder() {}
+
+		/**
+		 * Sets the IdP issuer URL.  OIDC discovery resolves the endpoints + JWKS URI lazily.  Either
+		 * this or {@link #metadata(OidcMetadata)} is required.
+		 *
+		 * @param value The issuer URL.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder issuer(URI value) {
+			issuer = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the IdP metadata explicitly, bypassing discovery.  Either this or {@link #issuer(URI)}
+		 * is required.
+		 *
+		 * @param value The metadata.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder metadata(OidcMetadata value) {
+			metadata = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the OAuth client id.  Required.
+		 *
+		 * @param value The client id.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder clientId(String value) {
+			clientId = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the client secret.  May be omitted for public clients (PKCE-only).
+		 *
+		 * @param value The client secret.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder clientSecret(String value) {
+			assertArgNotNullOrBlank("value", value);
+			clientSecretSupplier = () -> value;
+			return this;
+		}
+
+		/**
+		 * Sets the client secret via a supplier.
+		 *
+		 * @param value The supplier.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder clientSecretSupplier(Supplier<String> value) {
+			clientSecretSupplier = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the redirect URI registered with the IdP.  Required.
+		 *
+		 * @param value The redirect URI.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder redirectUri(URI value) {
+			redirectUri = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the post-logout redirect URI passed to the IdP end-session endpoint.
+		 *
+		 * @param value The post-logout redirect URI.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder postLogoutRedirectUri(URI value) {
+			postLogoutRedirectUri = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the default post-login redirect target (when the login request supplied no safe
+		 * {@code redirect} parameter).  Defaults to {@code "/"}.
+		 *
+		 * @param value The default redirect path.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder postLoginRedirect(String value) {
+			postLoginRedirect = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Adds requested scopes.  {@code openid} is always included.
+		 *
+		 * @param values The scopes.
+		 * @return This object.
+		 */
+		public Builder scope(String...values) {
+			assertArgNotNull("values", values);
+			for (var v : values) {
+				assertArgNotNullOrBlank("scope", v);
+				scopes.add(v);
+			}
+			return this;
+		}
+
+		/**
+		 * Sets the session store.  Required.
+		 *
+		 * @param value The session store.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder sessionStore(SessionStore value) {
+			sessionStore = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the claim roles are extracted from.  Defaults to {@code "scope"} (whitespace-split);
+		 * array claims are also accepted.  Mirrors the {@code OAuthFilter} convention.
+		 *
+		 * @param value The claim name.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder rolesClaim(String value) {
+			rolesClaim = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the single-use TTL for the {@code state} / {@code nonce} store.  Defaults to 5 minutes.
+		 *
+		 * @param value The TTL.  Must be positive and not exceed 30 minutes.
+		 * @return This object.
+		 */
+		public Builder stateNonceTtl(Duration value) {
+			stateNonceTtl = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the session lifetime (bounded independently of token lifetime).  Defaults to 8 hours.
+		 *
+		 * @param value The session TTL.  Must be positive.
+		 * @return This object.
+		 */
+		public Builder sessionTtl(Duration value) {
+			assertArgNotNull("value", value);
+			assertArg(!value.isZero() && !value.isNegative(), "sessionTtl must be positive");
+			sessionTtl = value;
+			return this;
+		}
+
+		/**
+		 * Sets the session cookie name.  Defaults to {@code JUNEAU_OIDC_SESSION}.
+		 *
+		 * @param value The cookie name.  Must not be <jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder cookieName(String value) {
+			cookieName = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the cookie {@code Secure} flag.  Defaults to <jk>true</jk> (fail-closed).
+		 *
+		 * @param value The flag.
+		 * @return This object.
+		 */
+		public Builder cookieSecure(boolean value) {
+			cookieSecure = value;
+			return this;
+		}
+
+		/**
+		 * Sets the cookie {@code HttpOnly} flag.  Defaults to <jk>true</jk> (fail-closed).
+		 *
+		 * @param value The flag.
+		 * @return This object.
+		 */
+		public Builder cookieHttpOnly(boolean value) {
+			cookieHttpOnly = value;
+			return this;
+		}
+
+		/**
+		 * Sets the cookie {@code SameSite} attribute.  Defaults to {@code "Lax"}.
+		 *
+		 * @param value The SameSite value (e.g. {@code "Lax"} or {@code "Strict"}).  Must not be
+		 * 	<jk>null</jk> or blank.
+		 * @return This object.
+		 */
+		public Builder cookieSameSite(String value) {
+			cookieSameSite = assertArgNotNullOrBlank("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets a Nimbus {@link HTTPRequest} configurator escape hatch (proxy / TLS / timeouts) applied
+		 * to discovery + token + userinfo calls.
+		 *
+		 * @param value The callback.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
+			httpRequestConfigurator = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets a Nimbus {@link AuthenticationRequest.Builder} customizer escape hatch (e.g. {@code prompt},
+		 * {@code max_age}, {@code acr_values}) applied to the authorization redirect.
+		 *
+		 * @param value The callback.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder authenticationRequestCustomizer(Consumer<AuthenticationRequest.Builder> value) {
+			authenticationRequestCustomizer = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Sets the ID-token signing-algorithm allowlist.  Defaults to {@code [RS256, ES256]}.
+		 *
+		 * @param values The allowed algorithms.
+		 * @return This object.
+		 */
+		public Builder idTokenAlgorithms(JWSAlgorithm...values) {
+			idTokenAlgorithms = assertArgNotNull("values", values);
+			return this;
+		}
+
+		/**
+		 * Sets the clock-skew tolerance (seconds) for ID-token validation.  Defaults to 60.
+		 *
+		 * @param value The tolerance in seconds.  Must be non-negative.
+		 * @return This object.
+		 */
+		public Builder clockSkewSeconds(int value) {
+			assertArg(value >= 0, "clockSkewSeconds must be non-negative (was {0})", value);
+			clockSkewSeconds = value;
+			return this;
+		}
+
+		/**
+		 * Injects a fixed JWK set for ID-token / logout-token signature verification, bypassing the
+		 * JWKS endpoint.  Useful for tests and static-key deployments.
+		 *
+		 * @param value The JWK set.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder jwkSet(JWKSet value) {
+			jwkSet = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Injects a custom JWK source for ID-token / logout-token signature verification.
+		 *
+		 * @param value The JWK source.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder jwkSource(JWKSource<SecurityContext> value) {
+			jwkSource = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Adds claim names to backfill from the UserInfo endpoint when absent from the ID token.
+		 *
+		 * <p>
+		 * When empty (the default) the UserInfo endpoint is never called &mdash; the {@link Principal}
+		 * is built from ID-token claims only.
+		 *
+		 * @param values The claim names to backfill.
+		 * @return This object.
+		 */
+		public Builder userInfoClaims(String...values) {
+			assertArgNotNull("values", values);
+			for (var v : values) {
+				assertArgNotNullOrBlank("claim", v);
+				userInfoClaims.add(v);
+			}
+			return this;
+		}
+
+		/**
+		 * Overrides the clock used for session expiry + the ephemeral store.  Useful in tests.
+		 *
+		 * @param value The clock.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder clock(Clock value) {
+			clock = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Builds the relying party.
+		 *
+		 * @return A new {@link OidcRelyingParty}.
+		 */
+		public OidcRelyingParty build() {
+			if (issuer == null && metadata == null)
+				throw new IllegalStateException("OidcRelyingParty requires issuer(...) or metadata(...)");
+			if (clientId == null)
+				throw new IllegalStateException("OidcRelyingParty requires clientId(...)");
+			if (redirectUri == null)
+				throw new IllegalStateException("OidcRelyingParty requires redirectUri(...)");
+			if (sessionStore == null)
+				throw new IllegalStateException("OidcRelyingParty requires sessionStore(...)");
+			return new OidcRelyingParty(this);
+		}
+	}
+
+	private final URI issuer;
+	private final String clientId;
+	private final Supplier<String> clientSecretSupplier;
+	private final URI redirectUri;
+	private final URI postLogoutRedirectUri;
+	private final String postLoginRedirect;
+	private final Set<String> scopes;
+	private final SessionStore sessionStore;
+	private final String rolesClaim;
+	private final Duration sessionTtl;
+	private final String cookieName;
+	private final boolean cookieSecure;
+	private final boolean cookieHttpOnly;
+	private final String cookieSameSite;
+	private final Consumer<HTTPRequest> httpRequestConfigurator;
+	private final Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer;
+	private final JWSAlgorithm[] idTokenAlgorithms;
+	private final int clockSkewSeconds;
+	private final JWKSet jwkSet;
+	private final JWKSource<SecurityContext> injectedJwkSource;
+	private final Set<String> userInfoClaims;
+	private final Clock clock;
+	private final EphemeralStore ephemeralStore;
+
+	private volatile OidcMetadata metadataCache;
+	private final OidcMetadata explicitMetadata;
+	private volatile OAuthAuthorizationCodeFlow codeFlowCache;
+	private volatile IdTokenValidatorAdapter idTokenValidatorCache;
+	private volatile JWKSource<SecurityContext> jwkSourceCache;
+	private volatile LogoutTokenValidator logoutTokenValidatorCache;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param b The builder.
+	 */
+	protected OidcRelyingParty(Builder b) {
+		this.issuer = b.issuer;
+		this.explicitMetadata = b.metadata;
+		this.clientId = b.clientId;
+		this.clientSecretSupplier = b.clientSecretSupplier;
+		this.redirectUri = b.redirectUri;
+		this.postLogoutRedirectUri = b.postLogoutRedirectUri;
+		this.postLoginRedirect = b.postLoginRedirect;
+		var s = new LinkedHashSet<String>();
+		s.add("openid");
+		s.addAll(b.scopes);
+		this.scopes = Collections.unmodifiableSet(s);
+		this.sessionStore = b.sessionStore;
+		this.rolesClaim = b.rolesClaim;
+		this.sessionTtl = b.sessionTtl;
+		this.cookieName = b.cookieName;
+		this.cookieSecure = b.cookieSecure;
+		this.cookieHttpOnly = b.cookieHttpOnly;
+		this.cookieSameSite = b.cookieSameSite;
+		this.httpRequestConfigurator = b.httpRequestConfigurator;
+		this.authenticationRequestCustomizer = b.authenticationRequestCustomizer;
+		this.idTokenAlgorithms = b.idTokenAlgorithms;
+		this.clockSkewSeconds = b.clockSkewSeconds;
+		this.jwkSet = b.jwkSet;
+		this.injectedJwkSource = b.jwkSource;
+		this.userInfoClaims = Collections.unmodifiableSet(new LinkedHashSet<>(b.userInfoClaims));
+		this.clock = b.clock;
+		this.ephemeralStore = new EphemeralStore(b.stateNonceTtl, b.ephemeralMaxEntries, b.clock);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Public flow methods
+	//-----------------------------------------------------------------------------------------------------------------
+
+	/**
+	 * Begins the login flow: generates {@code state} + {@code nonce} + PKCE verifier, stores them
+	 * single-use, and redirects the user-agent to the IdP authorization endpoint.
+	 *
+	 * @param req The HTTP request.  An optional {@code redirect} query parameter (a safe relative path)
+	 * 	is remembered as the post-login target.
+	 * @param res The HTTP response.
+	 * @throws IOException If the redirect cannot be written.
+	 */
+	public void startLogin(HttpServletRequest req, HttpServletResponse res) throws IOException {
+		assertArgNotNull("req", req);
+		assertArgNotNull("res", res);
+		var state = new State().getValue();
+		var nonce = new Nonce().getValue();
+		var verifier = new CodeVerifier();
+		var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, verifier);
+		var redirectTarget = safeRelativePath(req.getParameter("redirect"));
+		ephemeralStore.store(state, nonce, verifier.getValue(), redirectTarget);
+		var authUrl = codeFlow().buildAuthenticationUrl(state, challenge, nonce, authenticationRequestCustomizer);
+		noStore(res);
+		res.sendRedirect(authUrl.toString());
+	}
+
+	/**
+	 * Completes the login flow: parses the callback, verifies + consumes {@code state}, exchanges the
+	 * code (PKCE), validates the ID token (signature / {@code iss} / {@code aud} / {@code exp} /
+	 * {@code nonce}), creates a session, sets the session cookie, and redirects to the application.
+	 *
+	 * @param req The callback HTTP request (carrying {@code code} + {@code state}).
+	 * @param res The HTTP response.
+	 * @throws IOException If the redirect cannot be written.
+	 * @throws AuthenticationException If the callback is an error, the state is missing / replayed, or
+	 * 	ID-token validation fails.
+	 */
+	public void completeLogin(HttpServletRequest req, HttpServletResponse res) throws IOException, AuthenticationException {
+		assertArgNotNull("req", req);
+		assertArgNotNull("res", res);
+
+		var callbackUri = fullRequestUri(req);
+		AuthenticationResponse parsed;
+		try {
+			parsed = AuthenticationResponseParser.parse(callbackUri);
+		} catch (com.nimbusds.oauth2.sdk.ParseException e) {
+			throw new AuthenticationException(e, "OIDC callback could not be parsed");
+		}
+		if (! parsed.indicatesSuccess()) {
+			var err = parsed.toErrorResponse().getErrorObject();
+			throw new AuthenticationException("OIDC callback returned an error: {0}", err == null ? "unknown" : err.getCode());
+		}
+		var success = parsed.toSuccessResponse();
+		if (success.getAuthorizationCode() == null)
+			throw new AuthenticationException("OIDC callback contained no authorization code");
+		var code = success.getAuthorizationCode().getValue();
+		var state = success.getState() == null ? null : success.getState().getValue();
+
+		var pending = ephemeralStore.consume(state).orElseThrow(
+			() -> new AuthenticationException("OIDC callback state is missing, expired, or already used"));
+
+		OAuthToken token;
+		try {
+			token = codeFlow().exchange(code, new CodeVerifier(pending.codeVerifier()));
+		} catch (OAuthFlowException e) {
+			throw new AuthenticationException(e, "OIDC token exchange failed");
+		}
+		var idToken = token.idToken().orElseThrow(
+			() -> new AuthenticationException("IdP token response contained no id_token"));
+
+		var principal = idTokenValidator().validate(idToken, pending.nonce());
+		principal = maybeBackfillUserInfo(principal, token);
+
+		var subject = principal.getName();
+		var sid = principal.getClaim("sid", String.class);
+		var roles = extractRoles(principal);
+		var now = clock.instant();
+		var session = new OidcSession(
+			new State().getValue(),
+			subject,
+			sid,
+			principal,
+			roles,
+			Optional.of(token),
+			now,
+			now.plus(sessionTtl));
+
+		var cookieValue = sessionStore.createSessionCookieValue(session);
+		noStore(res);
+		res.addHeader("Set-Cookie", buildSetCookie(cookieValue, sessionTtl.toSeconds()));
+		res.sendRedirect(pending.redirectTarget() != null ? pending.redirectTarget() : postLoginRedirect);
+	}
+
+	/**
+	 * Logs the user out: invalidates the session, clears the session cookie, and (when the IdP
+	 * advertises an end-session endpoint) redirects through it; otherwise redirects to the
+	 * post-logout target.
+	 *
+	 * @param req The HTTP request.
+	 * @param res The HTTP response.
+	 * @throws IOException If the redirect cannot be written.
+	 */
+	public void logout(HttpServletRequest req, HttpServletResponse res) throws IOException {
+		assertArgNotNull("req", req);
+		assertArgNotNull("res", res);
+		var cookieValue = readCookie(req);
+		JWT idTokenHint = null;
+		if (cookieValue != null) {
+			var session = sessionStore.lookup(cookieValue);
+			if (session.isPresent() && session.get().token().isPresent()) {
+				var idt = session.get().token().get().idToken();
+				if (idt.isPresent()) {
+					try {
+						idTokenHint = JWTParser.parse(idt.get());
+					} catch (java.text.ParseException e) {
+						idTokenHint = null;  // HTT - best-effort hint only
+					}
+				}
+			}
+			sessionStore.invalidate(cookieValue);
+		}
+		noStore(res);
+		res.addHeader("Set-Cookie", buildSetCookie("", 0));
+
+		var endSession = metadata().endSessionEndpoint();
+		if (endSession != null) {
+			var logoutReq = new LogoutRequest(endSession, idTokenHint, postLogoutRedirectUri, null);
+			res.sendRedirect(logoutReq.toURI().toString());
+		} else {
+			res.sendRedirect(postLogoutRedirectUri != null ? postLogoutRedirectUri.toString() : postLoginRedirect);
+		}
+	}
+
+	/**
+	 * Refreshes the current session's access token using the rotating-refresh-token grant.
+	 *
+	 * <p>
+	 * Replaces the stored refresh token with the one the IdP returns; if the IdP rejects the refresh
+	 * (e.g. a rotated token was reused / revoked), the session is invalidated.  Only applies to stores
+	 * that retain tokens (server-side); {@link SignedCookieSessionStore} sessions carry no tokens and
+	 * yield {@link Optional#empty()}.
+	 *
+	 * @param req The HTTP request.
+	 * @param res The HTTP response (a refreshed session resets the cookie).
+	 * @return The refreshed session, or {@link Optional#empty()} if there was nothing to refresh.
+	 */
+	public Optional<OidcSession> refresh(HttpServletRequest req, HttpServletResponse res) {
+		assertArgNotNull("req", req);
+		assertArgNotNull("res", res);
+		var cookieValue = readCookie(req);
+		if (cookieValue == null)
+			return Optional.empty();
+		var existing = sessionStore.lookup(cookieValue);
+		if (existing.isEmpty() || existing.get().token().isEmpty())
+			return Optional.empty();
+		var oldToken = existing.get().token().get();
+		if (oldToken.refreshToken().isEmpty())
+			return Optional.empty();
+
+		OAuthToken refreshed;
+		try {
+			var flowBuilder = OAuthRefreshTokenFlow.create()
+				.tokenEndpoint(metadata().tokenEndpoint())
+				.clientId(clientId)
+				.refreshToken(oldToken.refreshToken().get());
+			if (clientSecretSupplier != null)
+				flowBuilder.clientSecretSupplier(clientSecretSupplier);
+			if (httpRequestConfigurator != null)
+				flowBuilder.httpRequestConfigurator(httpRequestConfigurator);
+			refreshed = flowBuilder.build().acquire();
+		} catch (OAuthFlowException e) {
+			// Rotating-refresh-token reuse / revocation -> invalidate the session (fail-closed).
+			sessionStore.invalidate(cookieValue);
+			res.addHeader("Set-Cookie", buildSetCookie("", 0));
+			return Optional.empty();
+		}
+
+		var old = existing.get();
+		var now = clock.instant();
+		var newSession = new OidcSession(
+			new State().getValue(),
+			old.subject(),
+			old.sid(),
+			old.principal(),
+			old.roles(),
+			Optional.of(refreshed),
+			old.createdAt(),
+			now.plus(sessionTtl));
+		sessionStore.invalidate(cookieValue);
+		var newCookie = sessionStore.createSessionCookieValue(newSession);
+		noStore(res);
+		res.addHeader("Set-Cookie", buildSetCookie(newCookie, sessionTtl.toSeconds()));
+		return Optional.of(newSession);
+	}
+
+	/**
+	 * Processes an IdP-pushed OpenID Connect back-channel logout token, invalidating the matching
+	 * server-side session(s).
+	 *
+	 * <p>
+	 * The logout token is validated via Nimbus's {@link LogoutTokenValidator} (signature against the
+	 * JWKS, {@code iss}, {@code aud}, the back-channel {@code events} claim, and the {@code sub} /
+	 * {@code sid} presence rule).  Sessions are then revoked by {@code sid} (preferred, single-session)
+	 * or {@code sub} (all of a subject's sessions).
+	 *
+	 * <p>
+	 * Requires a {@link SessionStore#supportsServerSideRevocation() server-side-revocable} store
+	 * &mdash; the default {@link SignedCookieSessionStore} cannot satisfy back-channel logout because a
+	 * stateless cookie is not server-revocable.
+	 *
+	 * @param logoutToken The raw {@code logout_token} JWT pushed by the IdP.  Must not be <jk>null</jk>
+	 * 	or blank.
+	 * @return The number of sessions invalidated.
+	 * @throws AuthenticationException If the logout token fails validation.
+	 * @throws IllegalStateException If the configured session store does not support server-side
+	 * 	revocation.
+	 */
+	public int backChannelLogout(String logoutToken) throws AuthenticationException {
+		assertArgNotNullOrBlank("logoutToken", logoutToken);
+		if (! sessionStore.supportsServerSideRevocation())
+			throw new IllegalStateException("Back-channel logout requires a server-side-revocable SessionStore (InMemorySessionStore or a distributed store); the configured store is stateless.");
+		JWT jwt;
+		try {
+			jwt = JWTParser.parse(logoutToken);
+		} catch (java.text.ParseException e) {
+			throw new AuthenticationException(e, "Logout token could not be parsed");
+		}
+		LogoutTokenClaimsSet claims;
+		try {
+			claims = logoutTokenValidator().validate(jwt);
+		} catch (com.nimbusds.jose.proc.BadJOSEException | com.nimbusds.jose.JOSEException e) {
+			throw new AuthenticationException(e, "Logout token validation failed: {0}", e.getMessage());
+		}
+		var sid = claims.getSessionID();
+		if (sid != null)
+			return sessionStore.invalidateBySessionId(sid.getValue());
+		var sub = claims.getSubject();
+		if (sub != null)
+			return sessionStore.invalidateBySubject(sub.getValue());
+		return 0;  // HTT - validator enforces sub/sid presence; defensive
+	}
+
+	/**
+	 * Returns a request-time {@link AuthFilter} that resolves this RP's session cookie into a
+	 * {@link ClaimsPrincipal}.
+	 *
+	 * @return A new {@link OidcSessionAuthFilter} bound to this RP's session store + cookie name.
+	 */
+	public OidcSessionAuthFilter authFilter() {
+		return new OidcSessionAuthFilter(sessionStore, cookieName);
+	}
+
+	/**
+	 * Returns the configured session store.
+	 *
+	 * @return The session store.
+	 */
+	public SessionStore sessionStore() {
+		return sessionStore;
+	}
+
+	/**
+	 * Returns the session cookie name.
+	 *
+	 * @return The cookie name.
+	 */
+	public String cookieName() {
+		return cookieName;
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Internals
+	//-----------------------------------------------------------------------------------------------------------------
+
+	private OidcMetadata metadata() {
+		var m = metadataCache;
+		if (m != null)
+			return m;
+		synchronized (this) {
+			if (metadataCache != null)
+				return metadataCache;
+			if (explicitMetadata != null) {
+				metadataCache = explicitMetadata;
+				return metadataCache;
+			}
+			try {
+				var clientBuilder = OidcDiscoveryClient.create().issuer(issuer);
+				if (httpRequestConfigurator != null)
+					clientBuilder.httpRequestConfigurator(httpRequestConfigurator);
+				metadataCache = clientBuilder.build().discover();
+			} catch (IOException | OidcDiscoveryException e) {
+				throw new IllegalStateException("OIDC discovery failed for issuer " + issuer, e);
+			}
+			return metadataCache;
+		}
+	}
+
+	private OAuthAuthorizationCodeFlow codeFlow() {
+		var f = codeFlowCache;
+		if (f != null)
+			return f;
+		synchronized (this) {
+			if (codeFlowCache != null)
+				return codeFlowCache;
+			var md = metadata();
+			var b = OAuthAuthorizationCodeFlow.create()
+				.authorizationEndpoint(md.authorizationEndpoint())
+				.tokenEndpoint(md.tokenEndpoint())
+				.clientId(clientId)
+				.redirectUri(redirectUri)
+				.scope(scopes.toArray(new String[0]));
+			if (clientSecretSupplier != null)
+				b.clientSecretSupplier(clientSecretSupplier);
+			if (httpRequestConfigurator != null)
+				b.httpRequestConfigurator(httpRequestConfigurator);
+			codeFlowCache = b.build();
+			return codeFlowCache;
+		}
+	}
+
+	private IdTokenValidatorAdapter idTokenValidator() {
+		var v = idTokenValidatorCache;
+		if (v != null)
+			return v;
+		synchronized (this) {
+			if (idTokenValidatorCache != null)
+				return idTokenValidatorCache;
+			var b = IdTokenValidatorAdapter.create()
+				.issuer(metadata().issuer().toString())
+				.clientId(clientId)
+				.jwkSource(jwkSource())
+				.maxClockSkewSeconds(clockSkewSeconds);
+			if (idTokenAlgorithms != null)
+				b.algorithms(idTokenAlgorithms);
+			idTokenValidatorCache = b.build();
+			return idTokenValidatorCache;
+		}
+	}
+
+	private JWKSource<SecurityContext> jwkSource() {
+		var s = jwkSourceCache;
+		if (s != null)
+			return s;
+		synchronized (this) {
+			if (jwkSourceCache != null)
+				return jwkSourceCache;
+			if (injectedJwkSource != null) {
+				jwkSourceCache = injectedJwkSource;
+			} else if (jwkSet != null) {
+				jwkSourceCache = new ImmutableJWKSet<>(jwkSet);
+			} else {
+				try {
+					jwkSourceCache = JWKSourceBuilder.create(metadata().jwksUri().toURL()).build();
+				} catch (MalformedURLException e) {
+					throw new IllegalStateException("Invalid JWKS URI: " + metadata().jwksUri(), e);
+				}
+			}
+			return jwkSourceCache;
+		}
+	}
+
+	private LogoutTokenValidator logoutTokenValidator() {
+		var v = logoutTokenValidatorCache;
+		if (v != null)
+			return v;
+		synchronized (this) {
+			if (logoutTokenValidatorCache != null)
+				return logoutTokenValidatorCache;
+			Set<JWSAlgorithm> algs = idTokenAlgorithms != null
+				? new LinkedHashSet<>(Arrays.asList(idTokenAlgorithms))
+				: new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256));
+			var keySelector = new JWSVerificationKeySelector<SecurityContext>(algs, jwkSource());
+			logoutTokenValidatorCache = new LogoutTokenValidator(
+				new Issuer(metadata().issuer().toString()), new ClientID(clientId), keySelector, null);
+			return logoutTokenValidatorCache;
+		}
+	}
+
+	private ClaimsPrincipal maybeBackfillUserInfo(ClaimsPrincipal principal, OAuthToken token) {
+		if (userInfoClaims.isEmpty())
+			return principal;
+		var endpoint = metadata().userinfoEndpoint();
+		if (endpoint == null)
+			return principal;
+		var missing = userInfoClaims.stream().anyMatch(c -> ! principal.hasClaim(c));
+		if (! missing)
+			return principal;
+		try {
+			var http = new UserInfoRequest(endpoint, new BearerAccessToken(token.accessToken())).toHTTPRequest();
+			if (httpRequestConfigurator != null)
+				httpRequestConfigurator.accept(http);
+			var resp = UserInfoResponse.parse(http.send());
+			if (! resp.indicatesSuccess())
+				return principal;
+			var ui = resp.toSuccessResponse().getUserInfo().toJSONObject();
+			var merged = new LinkedHashMap<String,Object>(principal.getClaims());
+			for (var c : userInfoClaims)
+				if (! merged.containsKey(c) && ui.containsKey(c))
+					merged.put(c, ui.get(c));
+			return new ClaimsPrincipal(principal.getName(), merged);
+		} catch (IOException | com.nimbusds.oauth2.sdk.ParseException | RuntimeException e) {
+			return principal;  // UserInfo backfill is best-effort; never fail the login on it.
+		}
+	}
+
+	private Set<String> extractRoles(ClaimsPrincipal principal) {
+		var v = principal.getClaims().get(rolesClaim);
+		if (v == null)
+			return Collections.emptySet();
+		var out = new LinkedHashSet<String>();
+		if (v instanceof String str) {
+			for (var piece : str.split("\\s+"))
+				if (! piece.isBlank())
+					out.add(piece);
+		} else if (v instanceof Collection<?> list) {
+			for (var item : list)
+				if (item instanceof String str)
+					out.add(str);
+		}
+		return out;
+	}
+
+	private String buildSetCookie(String value, long maxAgeSeconds) {
+		var sb = new StringBuilder();
+		sb.append(cookieName).append('=').append(value);
+		sb.append("; Path=/");
+		sb.append("; Max-Age=").append(maxAgeSeconds);
+		if (cookieHttpOnly)
+			sb.append("; HttpOnly");
+		if (cookieSecure)
+			sb.append("; Secure");
+		if (cookieSameSite != null)
+			sb.append("; SameSite=").append(cookieSameSite);
+		return sb.toString();
+	}
+
+	private String readCookie(HttpServletRequest req) {
+		var cookies = req.getCookies();
+		if (cookies == null)
+			return null;
+		for (var c : cookies)
+			if (cookieName.equals(c.getName()) && c.getValue() != null && ! c.getValue().isBlank())
+				return c.getValue();
+		return null;
+	}
+
+	private static void noStore(HttpServletResponse res) {
+		res.setHeader("Cache-Control", "no-store");
+		res.setHeader("Pragma", "no-cache");
+	}
+
+	private static URI fullRequestUri(HttpServletRequest req) {
+		var url = req.getRequestURL();
+		var qs = req.getQueryString();
+		if (qs != null)
+			url.append('?').append(qs);
+		return URI.create(url.toString());
+	}
+
+	/**
+	 * Returns the value only if it is a safe app-relative path (defeats open-redirect via the
+	 * {@code redirect} parameter).
+	 */
+	private static String safeRelativePath(String value) {
+		if (value == null || value.isBlank())
+			return null;
+		if (! value.startsWith("/"))
+			return null;
+		if (value.startsWith("//") || value.contains("\\") || value.contains("://"))
+			return null;
+		return value;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java
new file mode 100644
index 0000000000..cf9c35af63
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java
@@ -0,0 +1,91 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import java.time.Instant;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.apache.juneau.rest.auth.oauth.OAuthToken;
+
+/**
+ * Immutable record describing a logged-in OpenID Connect Relying Party session.
+ *
+ * <p>
+ * Created by {@link OidcRelyingParty#completeLogin} after a successful login and persisted via a
+ * {@link SessionStore}.  The {@link #principal()} carries the verified ID-token claims and is what the
+ * {@link OidcSessionAuthFilter} surfaces to {@code RoleBasedRestGuard} / {@code @Auth Principal}.
+ *
+ * <p>
+ * Two fields support server-side (back-channel) revocation: {@link #subject()} (the {@code sub} claim)
+ * and {@link #sid()} (the optional {@code sid} session-id claim).  A server-side-indexed
+ * {@link SessionStore} keys on these so an IdP-pushed logout token can invalidate the matching
+ * session(s).
+ *
+ * @param id The opaque server-side session id.  For {@link InMemorySessionStore} this is the cache key;
+ * 	for {@link SignedCookieSessionStore} it is a stable identifier embedded in the signed cookie.  Never
+ * 	{@code null}.
+ * @param subject The ID-token {@code sub} claim.  Never {@code null} or blank.
+ * @param sid The ID-token {@code sid} (session id) claim, if the IdP issued one.  Used for back-channel
+ * 	logout targeting a single session rather than all of a subject's sessions.
+ * @param principal The verified ID-token claims as a {@link ClaimsPrincipal}.  Never {@code null}.
+ * @param roles The roles extracted from the configured roles claim.  Never {@code null}; may be empty.
+ * @param token The tokens returned from the token endpoint (access / refresh / id), if the store retains
+ * 	them.  {@link SignedCookieSessionStore} does NOT retain tokens (stateless cookies don't carry them),
+ * 	so this is {@link Optional#empty()} for cookie-backed sessions.
+ * @param createdAt The instant the session was created.  Never {@code null}.
+ * @param expiresAt The instant the session expires (bounded independently of token lifetime).  Never
+ * 	{@code null}.
+ * @since 9.5.0
+ */
+public record OidcSession(
+		String id,
+		String subject,
+		Optional<String> sid,
+		ClaimsPrincipal principal,
+		Set<String> roles,
+		Optional<OAuthToken> token,
+		Instant createdAt,
+		Instant expiresAt) {
+
+	/**
+	 * Compact constructor enforcing non-null fields and defensively copying the role set.
+	 */
+	public OidcSession {
+		Objects.requireNonNull(id, "id");
+		Objects.requireNonNull(subject, "subject");
+		Objects.requireNonNull(sid, "sid");
+		Objects.requireNonNull(principal, "principal");
+		Objects.requireNonNull(token, "token");
+		Objects.requireNonNull(createdAt, "createdAt");
+		Objects.requireNonNull(expiresAt, "expiresAt");
+		roles = roles == null
+			? Collections.emptySet()
+			: Collections.unmodifiableSet(new LinkedHashSet<>(roles));
+	}
+
+	/**
+	 * Returns whether this session is expired relative to the supplied instant.
+	 *
+	 * @param now The reference instant.
+	 * @return <jk>true</jk> if {@code now} is at or after {@link #expiresAt()}.
+	 */
+	public boolean isExpired(Instant now) {
+		Objects.requireNonNull(now, "now");
+		return !now.isBefore(expiresAt);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java
new file mode 100644
index 0000000000..38c8e7e1fd
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java
@@ -0,0 +1,87 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.auth.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * {@link AuthFilter} that resolves the OIDC Relying Party session cookie into a {@link ClaimsPrincipal}
+ * on each request, so {@code RoleBasedRestGuard} and {@code @Auth Principal} injection see the
+ * filter-resolved identity (via {@link AuthenticatedRequestWrapper}) with zero downstream changes.
+ *
+ * <p>
+ * Resolution is fail-open to <i>unauthenticated</i> (not 401): a request with no session cookie, or with
+ * an expired / tampered / unknown cookie, returns {@link Optional#empty()} so the chain passes through
+ * and the application's normal "not logged in" handling (typically a redirect to the login endpoint)
+ * applies.  A valid session returns {@link AuthResult} carrying the session principal and roles.
+ *
+ * <p>
+ * Like all {@link AuthFilter}s, this is usable standalone or composed inside an {@code AuthFilterChain}.
+ * Note that Juneau's {@code MockRest} does not execute servlet filters, so integration tests drive
+ * {@link #authenticate(HttpServletRequest)} / {@code doFilter} directly.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link OidcRelyingParty}
+ * 	<li class='jc'>{@link AuthFilter}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class OidcSessionAuthFilter extends AuthFilter {
+
+	private final SessionStore sessionStore;
+	private final String cookieName;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param sessionStore The session store to resolve cookies against.  Must not be <jk>null</jk>.
+	 * @param cookieName The session cookie name.  Must not be <jk>null</jk> or blank.
+	 */
+	public OidcSessionAuthFilter(SessionStore sessionStore, String cookieName) {
+		this.sessionStore = assertArgNotNull("sessionStore", sessionStore);
+		this.cookieName = assertArgNotNullOrBlank("cookieName", cookieName);
+	}
+
+	@Override /* Overridden from AuthFilter */
+	public Optional<AuthResult> authenticate(HttpServletRequest req) throws AuthenticationException {
+		var cookies = req.getCookies();
+		if (cookies == null)
+			return Optional.empty();
+		String value = null;
+		for (var c : cookies) {
+			if (cookieName.equals(c.getName())) {
+				value = c.getValue();
+				break;
+			}
+		}
+		if (value == null || value.isBlank())
+			return Optional.empty();
+		var session = sessionStore.lookup(value);
+		if (session.isEmpty())
+			return Optional.empty();
+		var s = session.get();
+		return Optional.of(AuthResult.of(s.principal(), s.roles()));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java
new file mode 100644
index 0000000000..a3726fa1b4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java
@@ -0,0 +1,129 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import java.util.Optional;
+
+/**
+ * SPI for persisting {@link OidcSession} instances and resolving them back from a session-cookie value.
+ *
+ * <p>
+ * Two implementations are bundled:
+ * <ul>
+ * 	<li>{@link SignedCookieSessionStore} &mdash; the documented default.  Stateless: the session is
+ * 		HMAC-signed and stored entirely in the cookie value.  Scales horizontally and survives restarts
+ * 		with no shared store, but <b>cannot be server-revoked</b> (so it does not support back-channel
+ * 		logout &mdash; see {@link #supportsServerSideRevocation()}).
+ * 	<li>{@link InMemorySessionStore} &mdash; the simpler single-instance / dev option.  Server-side
+ * 		indexed by {@code sub} and {@code sid}, so it <b>does</b> support back-channel logout.  Lost on
+ * 		restart; breaks under horizontal scaling without sticky sessions.
+ * </ul>
+ *
+ * <p>
+ * Distributed stores (Redis / JDBC) are caller-supplied via this SPI.  A distributed store that wants
+ * to participate in back-channel logout must implement {@link #invalidateBySubject(String)} /
+ * {@link #invalidateBySessionId(String)} and report {@link #supportsServerSideRevocation()} as
+ * <jk>true</jk>.
+ *
+ * <p>
+ * Implementations must be thread-safe.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link OidcSession}
+ * 	<li class='jc'>{@link InMemorySessionStore}
+ * 	<li class='jc'>{@link SignedCookieSessionStore}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public interface SessionStore {
+
+	/**
+	 * Persists the supplied session and returns the opaque value to set as the session cookie.
+	 *
+	 * <p>
+	 * For a server-side store this is typically a random session id used as a lookup key; for a
+	 * stateless cookie store it is the entire signed session payload.
+	 *
+	 * @param session The session to persist.  Must not be <jk>null</jk>.
+	 * @return The cookie value the client should send back on subsequent requests.  Never <jk>null</jk>.
+	 */
+	String createSessionCookieValue(OidcSession session);
+
+	/**
+	 * Resolves a session from a cookie value.
+	 *
+	 * @param cookieValue The cookie value previously returned by {@link #createSessionCookieValue}.
+	 * 	Must not be <jk>null</jk>.
+	 * @return The resolved session, or {@link Optional#empty()} if absent, expired, invalid, or
+	 * 	tampered.
+	 */
+	Optional<OidcSession> lookup(String cookieValue);
+
+	/**
+	 * Invalidates the session identified by the supplied cookie value.  No-op if absent.
+	 *
+	 * @param cookieValue The cookie value.  Must not be <jk>null</jk>.
+	 */
+	void invalidate(String cookieValue);
+
+	/**
+	 * Returns whether this store can revoke sessions server-side (a prerequisite for back-channel
+	 * logout).
+	 *
+	 * <p>
+	 * Defaults to <jk>false</jk> &mdash; stateless cookie stores cannot revoke a session the server
+	 * never retained.
+	 *
+	 * @return <jk>true</jk> if {@link #invalidateBySubject(String)} / {@link #invalidateBySessionId(String)}
+	 * 	are supported.
+	 */
+	default boolean supportsServerSideRevocation() {
+		return false;
+	}
+
+	/**
+	 * Invalidates every session belonging to the supplied subject ({@code sub}).
+	 *
+	 * <p>
+	 * Used by {@link OidcRelyingParty#backChannelLogout} when the IdP-pushed logout token carries a
+	 * {@code sub} but no {@code sid}.
+	 *
+	 * @param subject The subject identifier.  Must not be <jk>null</jk> or blank.
+	 * @return The number of sessions invalidated.
+	 * @throws UnsupportedOperationException If this store does not support server-side revocation.
+	 */
+	default int invalidateBySubject(String subject) {
+		throw new UnsupportedOperationException("This SessionStore does not support server-side revocation (back-channel logout requires an in-memory or distributed store).");
+	}
+
+	/**
+	 * Invalidates the session(s) bound to the supplied IdP session id ({@code sid}).
+	 *
+	 * <p>
+	 * Used by {@link OidcRelyingParty#backChannelLogout} when the IdP-pushed logout token carries a
+	 * {@code sid}.
+	 *
+	 * @param sid The IdP session id.  Must not be <jk>null</jk> or blank.
+	 * @return The number of sessions invalidated.
+	 * @throws UnsupportedOperationException If this store does not support server-side revocation.
+	 */
+	default int invalidateBySessionId(String sid) {
+		throw new UnsupportedOperationException("This SessionStore does not support server-side revocation (back-channel logout requires an in-memory or distributed store).");
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java
new file mode 100644
index 0000000000..a29cd6b750
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java
@@ -0,0 +1,232 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.nio.charset.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jwt.*;
+
+/**
+ * Stateless {@link SessionStore} that serializes the session into an HMAC-signed compact JWT carried
+ * entirely in the session cookie.  This is the documented default.
+ *
+ * <p>
+ * Because the session lives client-side, this store scales horizontally and survives server restarts
+ * with no shared store.  The trade-offs:
+ * <ul>
+ * 	<li><b>No server-side revocation</b> &mdash; {@link #supportsServerSideRevocation()} is
+ * 		<jk>false</jk>; a stateless cookie cannot be force-revoked, so this store does NOT support
+ * 		back-channel logout.  Use {@link InMemorySessionStore} or a caller-supplied distributed store
+ * 		if back-channel logout is required.
+ * 	<li><b>Size cap</b> &mdash; cookies are capped (~4&nbsp;KB); only minimal claims are stored, never
+ * 		the raw access / refresh / ID tokens.  An over-cap payload throws rather than silently truncating.
+ * 	<li><b>Key rotation</b> &mdash; rotating the signing key invalidates all live sessions (users are
+ * 		bounced to re-login).
+ * </ul>
+ *
+ * <p>
+ * The payload is signed with {@code HS256} using the configured signing key (which must be at least 256
+ * bits / 32 bytes).  Signature failures, expiry, and parse errors all resolve to
+ * {@link Optional#empty()} on {@link #lookup(String)} (fail-closed).
+ *
+ * @since 9.5.0
+ */
+public class SignedCookieSessionStore implements SessionStore {
+
+	/** Default maximum serialized cookie size in bytes. */
+	public static final int DEFAULT_MAX_COOKIE_BYTES = 4096;
+
+	private static final String CLAIM_NAME = "n";
+	private static final String CLAIM_ROLES = "r";
+	private static final String CLAIM_SID = "sid";
+	private static final String CLAIM_CLAIMS = "c";
+
+	/**
+	 * Static creator.
+	 *
+	 * @return A new builder.
+	 */
+	public static Builder create() {
+		return new Builder();
+	}
+
+	/**
+	 * Builder.
+	 */
+	public static class Builder {
+		private byte[] signingKey;
+		private int maxCookieBytes = DEFAULT_MAX_COOKIE_BYTES;
+		private Clock clock = Clock.systemUTC();
+
+		/** Constructor. */
+		protected Builder() {}
+
+		/**
+		 * Sets the HMAC signing key from a string (UTF-8 encoded).  Required.
+		 *
+		 * @param value The signing key.  Must be at least 32 bytes (256 bits) when UTF-8 encoded.
+		 * @return This object.
+		 */
+		public Builder signingKey(String value) {
+			assertArgNotNullOrBlank("value", value);
+			return signingKey(value.getBytes(StandardCharsets.UTF_8));
+		}
+
+		/**
+		 * Sets the HMAC signing key bytes.  Required.
+		 *
+		 * @param value The signing key.  Must be at least 32 bytes (256 bits).
+		 * @return This object.
+		 */
+		public Builder signingKey(byte[] value) {
+			assertArgNotNull("value", value);
+			assertArg(value.length >= 32, "signingKey must be at least 32 bytes (256 bits) for HS256; was {0}", value.length);
+			signingKey = value.clone();
+			return this;
+		}
+
+		/**
+		 * Sets the maximum serialized cookie size in bytes.
+		 *
+		 * @param value The cap.  Must be positive.
+		 * @return This object.
+		 */
+		public Builder maxCookieBytes(int value) {
+			assertArg(value > 0, "maxCookieBytes must be positive (was {0})", value);
+			maxCookieBytes = value;
+			return this;
+		}
+
+		/**
+		 * Overrides the clock used for expiry comparisons.  Useful in tests.
+		 *
+		 * @param value The clock.  Must not be <jk>null</jk>.
+		 * @return This object.
+		 */
+		public Builder clock(Clock value) {
+			clock = assertArgNotNull("value", value);
+			return this;
+		}
+
+		/**
+		 * Builds the store.
+		 *
+		 * @return A new {@link SignedCookieSessionStore}.
+		 */
+		public SignedCookieSessionStore build() {
+			if (signingKey == null)
+				throw new IllegalStateException("SignedCookieSessionStore requires signingKey(...)");
+			return new SignedCookieSessionStore(this);
+		}
+	}
+
+	private final byte[] signingKey;
+	private final int maxCookieBytes;
+	private final Clock clock;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param b The builder.
+	 */
+	protected SignedCookieSessionStore(Builder b) {
+		this.signingKey = b.signingKey;
+		this.maxCookieBytes = b.maxCookieBytes;
+		this.clock = b.clock;
+	}
+
+	@Override /* Overridden from SessionStore */
+	public String createSessionCookieValue(OidcSession session) {
+		assertArgNotNull("session", session);
+		try {
+			var cb = new JWTClaimsSet.Builder()
+				.subject(session.subject())
+				.jwtID(session.id())
+				.issueTime(Date.from(session.createdAt()))
+				.expirationTime(Date.from(session.expiresAt()))
+				.claim(CLAIM_NAME, session.principal().getName())
+				.claim(CLAIM_ROLES, new ArrayList<>(session.roles()))
+				.claim(CLAIM_CLAIMS, session.principal().getClaims());
+			session.sid().ifPresent(sid -> cb.claim(CLAIM_SID, sid));
+			var jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), cb.build());
+			jwt.sign(new MACSigner(signingKey));
+			var serialized = jwt.serialize();
+			if (serialized.getBytes(StandardCharsets.UTF_8).length > maxCookieBytes)
+				throw new IllegalStateException("Signed session cookie exceeds maxCookieBytes (" + maxCookieBytes
+					+ "); reduce the claim set or switch to a server-side SessionStore.");
+			return serialized;
+		} catch (JOSEException e) {
+			throw new IllegalStateException("Failed to sign session cookie", e);
+		}
+	}
+
+	@Override /* Overridden from SessionStore */
+	@SuppressWarnings("unchecked")
+	public Optional<OidcSession> lookup(String cookieValue) {
+		assertArgNotNull("cookieValue", cookieValue);
+		try {
+			var jwt = SignedJWT.parse(cookieValue);
+			if (! jwt.verify(new MACVerifier(signingKey)))
+				return Optional.empty();
+			var claims = jwt.getJWTClaimsSet();
+			var exp = claims.getExpirationTime();
+			if (exp == null || ! clock.instant().isBefore(exp.toInstant()))
+				return Optional.empty();
+			var subject = claims.getSubject();
+			var name = claims.getStringClaim(CLAIM_NAME);
+			if (subject == null || name == null)
+				return Optional.empty();
+			var roles = new LinkedHashSet<String>();
+			var rolesClaim = claims.getStringListClaim(CLAIM_ROLES);
+			if (rolesClaim != null)
+				roles.addAll(rolesClaim);
+			Map<String,Object> principalClaims = (Map<String,Object>) claims.getClaim(CLAIM_CLAIMS);
+			var principal = new ClaimsPrincipal(name, principalClaims);
+			var sid = Optional.ofNullable(claims.getStringClaim(CLAIM_SID));
+			var createdAt = claims.getIssueTime() != null ? claims.getIssueTime().toInstant() : clock.instant();
+			return Optional.of(new OidcSession(
+				claims.getJWTID(),
+				subject,
+				sid,
+				principal,
+				roles,
+				Optional.empty(),
+				createdAt,
+				exp.toInstant()));
+		} catch (java.text.ParseException | JOSEException | RuntimeException e) {
+			return Optional.empty();
+		}
+	}
+
+	@Override /* Overridden from SessionStore */
+	public void invalidate(String cookieValue) {
+		// Stateless: nothing to remove server-side.  The caller clears the cookie on the response.
+	}
+
+	@Override /* Overridden from SessionStore */
+	public boolean supportsServerSideRevocation() {
+		return false;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java
new file mode 100644
index 0000000000..b7e8ae4f2e
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+/**
+ * OpenID Connect Relying Party (RP) login support for Juneau REST servers.
+ *
+ * <p>
+ * This opt-in module turns a Juneau REST server into a complete OpenID Connect <i>Relying Party</i> &mdash;
+ * the "Log in with Google / Okta / Entra ID / Keycloak" end-to-end glue &mdash; sitting on top of
+ * {@code juneau-rest-server-oauth}.  It orchestrates the browser redirect &rarr; callback &rarr; session
+ * dance, owning the {@code state} / {@code nonce} / PKCE bookkeeping, strict ID-token validation, the
+ * session SPI, and the request-time session&rarr;identity binding.
+ *
+ * <p>
+ * The {@code com.nimbusds:oauth2-oidc-sdk} dependency is {@code provided}-scoped and contained at the
+ * module boundary &mdash; it never leaks transitively.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link org.apache.juneau.rest.auth.oidc.rp.OidcRelyingParty}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * 	<li class='link'><a class="doclink" href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core 1.0</a>
+ * </ul>
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
diff --git a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
index e4a0c8922c..be11f2a0a9 100644
--- a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
+++ b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
@@ -19,6 +19,8 @@ package org.apache.juneau.rest.tracing.otel;
 import static org.apache.juneau.commons.utils.AssertionUtils.*;
 import static org.apache.juneau.commons.utils.Utils.*;
 
+import java.util.*;
+
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.tracing.*;
 import org.apache.juneau.rest.tracing.Scope;
@@ -174,11 +176,35 @@ public class OtelTracerHook implements TracerHook {
 
 		Span span = spanBuilder.startSpan();
 		Context spanContext = extracted.with(span);
+
+		// TODO-114: Render the W3C trace-context headers from the server-started span's context (the only
+		// point where it's reliably active) and stash them as request attributes for
+		// TraceContextResponseProcessor to write on the response. The configured propagator only injects
+		// traceparent when the span context is valid, and tracestate only when non-empty, so both guards are
+		// handled here for free.
+		stashTraceContext(request, spanContext);
+
 		io.opentelemetry.context.Scope otelScope = spanContext.makeCurrent();
 
 		return new OtelScope(span, otelScope);
 	}
 
+	private void stashTraceContext(RestRequest request, Context spanContext) {
+		var carrier = new HashMap<String,String>(4);
+		propagator.inject(spanContext, carrier, MAP_SETTER);
+		var traceparent = carrier.get("traceparent");
+		if (nn(traceparent))
+			request.setAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT, traceparent);
+		var tracestate = carrier.get("tracestate");
+		if (nn(tracestate) && ! tracestate.isEmpty())
+			request.setAttribute(TraceContextResponseProcessor.ATTR_TRACESTATE, tracestate);
+	}
+
+	private static final TextMapSetter<HashMap<String,String>> MAP_SETTER = (carrier, key, value) -> {
+		if (nn(carrier))
+			carrier.put(key, value);
+	};
+
 	/**
 	 * Returns the {@link Tracer} this hook publishes spans to.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 152f395225..78a725ecd7 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -87,6 +87,7 @@ import org.apache.juneau.rest.debug.*;
 import org.apache.juneau.rest.debug.format.*;
 import org.apache.juneau.rest.httppart.*;
 import org.apache.juneau.rest.logger.*;
+import org.apache.juneau.rest.metrics.*;
 import org.apache.juneau.rest.processor.*;
 import org.apache.juneau.rest.rrpc.*;
 import org.apache.juneau.rest.servlet.*;
@@ -94,6 +95,7 @@ import org.apache.juneau.rest.staticfile.*;
 import org.apache.juneau.rest.stats.*;
 import org.apache.juneau.rest.openapi.*;
 import org.apache.juneau.rest.swagger.*;
+import org.apache.juneau.rest.tracing.*;
 import org.apache.juneau.rest.util.*;
 import org.apache.juneau.rest.validation.*;
 import org.apache.juneau.rest.vars.*;
@@ -283,6 +285,26 @@ public class RestContext extends Context {
 		 */
 		String[] paths;
 
+		/**
+		 * Programmatic override for the MDC async propagation flag.
+		 *
+		 * <p>
+		 * {@code null} (default) &mdash; defers to the {@code RestContext.mdcAsyncPropagation} env-driven
+		 * default (itself defaulting to {@code true} when SLF4J is detected). Set via
+		 * {@link #mdcAsyncPropagation(boolean)}.
+		 */
+		Boolean mdcAsyncPropagation;
+
+		/**
+		 * Programmatic override for the async-completion executor bean name.
+		 *
+		 * <p>
+		 * {@code null} (default) &mdash; resolved from the {@code @Rest(asyncCompletionExecutor)} annotation chain.
+		 * Set via {@link #asyncCompletionExecutor(String)} to supply the bean-name programmatically (useful in
+		 * test rigs that construct a {@link RestContext} directly without annotations).
+		 */
+		String asyncCompletionExecutorName;
+
 		/**
 		 * Package-private constructor.
 		 *
@@ -327,6 +349,49 @@ public class RestContext extends Context {
 			return this;
 		}
 
+		/**
+		 * Enables or disables SLF4J MDC propagation from the request thread to
+		 * {@link java.util.concurrent.CompletableFuture} completion threads.
+		 *
+		 * <p>
+		 * Default is {@code true} (enabled) when SLF4J is on the runtime classpath.
+		 * When enabled, the {@link org.apache.juneau.rest.processor.MdcAsyncListener} snapshots the
+		 * request thread's MDC map before the async dispatch and restores it inside the
+		 * {@link java.util.concurrent.CompletionStage#whenComplete(java.util.function.BiConsumer) whenComplete}
+		 * callback so that log statements emitted during async completion see the original request context.
+		 *
+		 * <p>
+		 * Call {@code mdcAsyncPropagation(false)} to opt a resource out of MDC propagation. The global default
+		 * can also be overridden via the {@code RestContext.mdcAsyncPropagation} env-driven default.
+		 *
+		 * @param value {@code false} to disable MDC propagation on this resource.
+		 * @return This object.
+		 * @since 9.5.0
+		 */
+		public Builder mdcAsyncPropagation(boolean value) {
+			mdcAsyncPropagation = value;
+			return this;
+		}
+
+		/**
+		 * Programmatically sets the {@link java.util.concurrent.Executor} bean name used to route
+		 * {@link java.util.concurrent.CompletableFuture} completion callbacks through a dedicated thread pool
+		 * (TODO-118). Equivalent to {@link Rest#asyncCompletionExecutor()} on the resource class.
+		 *
+		 * <p>
+		 * The value is looked up by name in the resource's bean store at context-build time. If the name does
+		 * not resolve to an {@link java.util.concurrent.Executor} bean, the context constructor throws. Pass
+		 * {@code null} (or omit this call) to clear the override and fall back to the annotation chain.
+		 *
+		 * @param beanName The bean-store name of the {@link java.util.concurrent.Executor} to use, or {@code null} to clear.
+		 * @return This object.
+		 * @since 9.5.0
+		 */
+		public Builder asyncCompletionExecutor(String beanName) {
+			asyncCompletionExecutorName = beanName;
+			return this;
+		}
+
 		@Override /* Context.Builder is abstract - copy() is not meaningful for the transient RestContext bootstrap state. */
 		public Builder copy() {
 			throw new NoSuchMethodError("Not implemented.");
@@ -1019,6 +1084,25 @@ public class RestContext extends Context {
 	@Value("${RestContext.virtualThreads:false}")
 	private boolean defaultVirtualThreads;
 
+	/**
+	 * Env-driven default controlling whether the {@link TraceContextResponseProcessor} is registered so the
+	 * server writes W3C {@code traceparent} / {@code tracestate} response headers when a {@link TracerHook} is
+	 * active (TODO-114). Defaults to {@code true} (on-when-tracer); set {@code RestContext.responseTraceparent=false}
+	 * to keep the processor out of the chain entirely.
+	 */
+	@Value("${RestContext.responseTraceparent:true}")
+	private boolean defaultResponseTraceparent;
+
+	/**
+	 * Env-driven default controlling whether {@link org.apache.juneau.rest.processor.MdcAsyncListener} propagates
+	 * the request thread's SLF4J MDC map to {@link java.util.concurrent.CompletableFuture} completion threads
+	 * (TODO-117). Defaults to {@code true} (on when SLF4J is detected). Set
+	 * {@code RestContext.mdcAsyncPropagation=false} to disable globally, or call
+	 * {@link Builder#mdcAsyncPropagation(boolean)} per resource.
+	 */
+	@Value("${RestContext.mdcAsyncPropagation:true}")
+	private boolean defaultMdcAsyncPropagation;
+
 	/** Env-driven default for {@code @Rest(eagerInit)}. */
 	@Value("${RestContext.eagerInit:false}")
 	private boolean defaultEagerInit;
@@ -1497,6 +1581,11 @@ public class RestContext extends Context {
 		// ResponseProcessorList.Builder.add(...) uses addAll (append) — final order: [DefaultConfig, parent, child].
 		var bs = beanStore();
 		var b = ResponseProcessorList.create(bs);
+		// TODO-114: When enabled (default on-when-tracer), front-load the W3C trace-context processor so it
+		// writes traceparent/tracestate headers before the body-rendering processors run. It short-circuits
+		// at request time when no TracerHook stashed a trace context, so it's zero-cost on the no-tracer path.
+		if (defaultResponseTraceparent)
+			b.add(TraceContextResponseProcessor.class);
 		getRestAnnotationsForProperty(PROPERTY_responseProcessors)
 			.forEach(ai -> b.add(ai.inner().responseProcessors()));
 		// @Bean method override REPLACES the entire annotation-derived list.
@@ -2112,12 +2201,21 @@ public class RestContext extends Context {
 				.addBean(AnnotationWorkList.class, annotationWork);
 			// @formatter:on
 
-			// Inject @Value-annotated env-driven defaults onto this RestContext instance so that the
-			// memoizer lambdas below (which run lazily on first .get() call) read fully resolved values.
-			// Must run BEFORE isEagerInit() since the eagerInit memoizer itself reads defaultEagerInit.
-			ClassInfo.of(this).inject(this, beanStore);
+		// Inject @Value-annotated env-driven defaults onto this RestContext instance so that the
+		// memoizer lambdas below (which run lazily on first .get() call) read fully resolved values.
+		// Must run BEFORE isEagerInit() since the eagerInit memoizer itself reads defaultEagerInit.
+		ClassInfo.of(this).inject(this, beanStore);
 
-			if (isEagerInit()) {
+		// Startup-fail if @Rest(observability="true") is set but neither MetricsRecorder nor TracerHook is
+		// supplied. The check runs after all @Bean method injection so that consumer-provided beans are visible.
+		checkObservabilityBackendPresent();
+
+		// Startup-fail (TODO-118): if asyncCompletionExecutor is configured (either by annotation or
+		// programmatically), force-evaluate the memoizer now so "bean not found" surfaces at context
+		// init time rather than lazily on the first request.
+		checkAsyncCompletionExecutorPresent();
+
+		if (isEagerInit()) {
 				// Force-fire the framework-bean memoizers in dependency-friendly order so their @Rest()
 				// annotation walks (e.g. `@Rest(partParser=…)`, `@Rest(partSerializer=…)`, `@Rest(encoders=…)`,
 				// etc.) execute eagerly inside the try-catch.  These walks MUTATE the cached creators that
@@ -2354,6 +2452,17 @@ public class RestContext extends Context {
 	private final Memoizer<Boolean> virtualThreadsEnabled = memoizer(() ->
 		mergeReplacedBooleanAttribute(PROPERTY_virtualThreads, defaultVirtualThreads));
 
+	/**
+	 * Resource-level observability tri-state from {@code @Rest(observability)}: {@code "true"} (strict opt-in),
+	 * {@code "false"} (explicit opt-out), or {@code null} / empty (default inherit behavior).
+	 *
+	 * <p>
+	 * Per-operation overrides (from verb / {@code @RestOp} annotations) are resolved independently in
+	 * {@link RestOpContext}.
+	 */
+	private final Memoizer<String> observabilityAttribute = memoizer(() ->
+		mergeReplacedStringAttribute(PROPERTY_observability, null));
+
 	/**
 	 * Configurable async-response timeout (milliseconds) applied by {@code AsyncResponseProcessor} to
 	 * {@link CompletableFuture}-returning handlers; resolved from {@code @Rest(asyncTimeoutMillis)} (TODO-70).
@@ -2374,6 +2483,38 @@ public class RestContext extends Context {
 		}
 	});
 
+	/**
+	 * The async-completion executor for this resource, resolved by name from the bean store; {@code null} when
+	 * {@code @Rest(asyncCompletionExecutor)} is blank (the common case).
+	 *
+	 * <p>
+	 * The memoizer throws {@link IllegalStateException} at first access if the bean name was supplied but no
+	 * matching {@link Executor} bean is found — this surfaces as a startup failure, not a silent no-op (TODO-118).
+	 */
+	private final Memoizer<Executor> asyncCompletionExecutor = memoizer(() -> {
+		// Programmatic override takes priority over the annotation chain.
+		var name = resolveAsyncCompletionExecutorName();
+		if (name == null || name.isBlank())
+			return null;
+		var resolved = beanStore().getBean(Executor.class, name);
+		// Try ExecutorService as well (a common supertype users supply that is-a Executor).
+		if (resolved.isEmpty())
+			resolved = beanStore().getBean(ExecutorService.class, name).map(e -> (Executor) e);
+		if (resolved.isEmpty())
+			throw new IllegalStateException(
+				"@Rest(asyncCompletionExecutor) on " + getResourceClass().getName()
+					+ " references bean name '" + name + "' but no Executor (or ExecutorService) bean"
+					+ " with that name is registered in the resource's bean store.");
+		return resolved.get();
+	});
+
+	// Extracted to a method so the compiler does not flag a blank-final-field access inside a field-initializer lambda.
+	private String resolveAsyncCompletionExecutorName() {
+		return builder.asyncCompletionExecutorName != null
+			? builder.asyncCompletionExecutorName
+			: mergeReplacedStringAttribute(PROPERTY_asyncCompletionExecutor, null);
+	}
+
 	/**
 	 * Lazily-instantiated virtual-thread executor used by {@link RestOpInvoker} when
 	 * {@code @Rest(virtualThreads=true)} is set on this resource and the runtime is Java 21+.
@@ -3568,6 +3709,66 @@ public class RestContext extends Context {
 	 */
 	public boolean isVirtualThreadsEnabled() { return virtualThreadsEnabled.get(); }
 
+	/**
+	 * Returns the raw resource-level observability attribute value from {@code @Rest(observability)}.
+	 *
+	 * <ul class='values'>
+	 * 	<li>{@code "true"} &mdash; strict opt-in: all operations on this resource require a wired backend.
+	 * 	<li>{@code "false"} &mdash; explicit opt-out: the observability block is disabled for all operations.
+	 * 	<li>{@code null} / empty &mdash; default (inherit / no-op fallback) behavior.
+	 * </ul>
+	 *
+	 * <p>
+	 * Per-operation overrides from {@link org.apache.juneau.rest.annotation.RestOp#observability()} (and verb
+	 * annotations) take precedence over this value; they are resolved in {@link RestOpContext}.
+	 *
+	 * @return The resolved {@code @Rest(observability)} string, or {@code null} when not set.
+	 */
+	public String getObservabilityAttribute() { return observabilityAttribute.get(); }
+
+	/**
+	 * Returns whether the resource explicitly opted out of observability via {@code @Rest(observability="false")}.
+	 *
+	 * @return <jk>true</jk> when the resource-level observability is disabled.
+	 */
+	public boolean isObservabilityDisabled() {
+		var v = observabilityAttribute.get();
+		return v != null && v.equalsIgnoreCase("false");
+	}
+
+	/**
+	 * Returns whether the server writes W3C {@code traceparent} / {@code tracestate} response headers when a
+	 * {@link TracerHook} is active on the request (TODO-114).
+	 *
+	 * <p>
+	 * Defaults to {@code true} (on-when-tracer); resolved from the {@code RestContext.responseTraceparent}
+	 * env-driven default. When {@code true}, a {@link TraceContextResponseProcessor} is registered in the
+	 * response-processor chain &mdash; it writes the headers only when a non-no-op {@code TracerHook} stashed a
+	 * trace context for the request, so it stays zero-cost on the no-tracer path. When {@code false}, the
+	 * processor is not registered at all.
+	 *
+	 * @return <jk>true</jk> if trace-context response-header propagation is enabled on this resource.
+	 */
+	public boolean isResponseTraceparent() { return defaultResponseTraceparent; }
+
+	/**
+	 * Returns whether SLF4J MDC propagation is enabled for {@link java.util.concurrent.CompletableFuture}
+	 * completion threads on this resource (TODO-117).
+	 *
+	 * <p>
+	 * Defaults to {@code true}; resolved first from the programmatic {@link Builder#mdcAsyncPropagation(boolean)}
+	 * override (if set), then from the {@code RestContext.mdcAsyncPropagation} env-driven default.
+	 * When {@code true}, {@link org.apache.juneau.rest.processor.MdcAsyncListener} wraps the
+	 * {@link java.util.concurrent.CompletionStage#whenComplete(java.util.function.BiConsumer) whenComplete} callback
+	 * registered by {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} so the completion thread sees
+	 * the request thread's MDC context. When {@code false}, no MDC work is done.
+	 *
+	 * @return <jk>true</jk> if MDC async propagation is enabled on this resource.
+	 */
+	public boolean isMdcAsyncPropagation() {
+		return builder.mdcAsyncPropagation != null ? builder.mdcAsyncPropagation : defaultMdcAsyncPropagation;
+	}
+
 	/**
 	 * Returns the lazily-instantiated virtual-thread executor for this resource, or {@code null} when
 	 * {@code @Rest(virtualThreads=true)} is unset, the runtime is older than Java 21, or executor construction
@@ -3586,6 +3787,16 @@ public class RestContext extends Context {
 	 */
 	public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
 
+	/**
+	 * Returns the {@link java.util.concurrent.Executor} configured for routing
+	 * {@link java.util.concurrent.CompletableFuture} completion callbacks on this resource (TODO-118), or
+	 * {@code null} when {@code @Rest(asyncCompletionExecutor)} is unset (natural completion thread).
+	 *
+	 * @return The executor, or {@code null} if no async-completion executor is configured.
+	 * @since 9.5.0
+	 */
+	public Executor getAsyncCompletionExecutor() { return asyncCompletionExecutor.get(); }
+
 	/**
 	 * Returns whether framework beans and operation/child contexts are eagerly initialized at construction time.
 	 *
@@ -3659,6 +3870,52 @@ public class RestContext extends Context {
 	 * Ensures framework bean memoizers that can mutate creator state from {@code @Rest(...)} annotation walks
 	 * have run before {@link RestOpContext} instances are built.
 	 */
+	/**
+	 * Validates that when {@code @Rest(observability="true")} is declared on this resource, at least one real
+	 * observability backend ({@link org.apache.juneau.rest.metrics.MetricsRecorder} or
+	 * {@link org.apache.juneau.rest.tracing.TracerHook}) is registered in the bean store.
+	 *
+	 * <p>
+	 * Called once during {@link RestContext} construction, after all {@code @Bean} method injection has run.
+	 * If the attribute is {@code "true"} and both beans resolve to their {@code NoOp} singletons, construction
+	 * fails with a {@link org.apache.juneau.rest.BasicHttpException InternalServerError} whose message precisely
+	 * identifies the missing backends.
+	 *
+	 * <p>
+	 * Per-operation {@code @RestOp(observability="true")} startup-fails are handled the same way inside
+	 * {@link RestOpContext}.
+	 *
+	 * @throws InternalServerError when {@code @Rest(observability="true")} is set but no backend is wired.
+	 */
+	private void checkObservabilityBackendPresent() {
+		var attr = observabilityAttribute.get();
+		if (!"true".equalsIgnoreCase(attr))
+			return;
+		var recorder = beanStore.getBean(MetricsRecorder.class).orElse(null);
+		var tracer = beanStore.getBean(TracerHook.class).orElse(null);
+		boolean hasRecorder = recorder != null && !(recorder instanceof NoOpMetricsRecorder);
+		boolean hasTracer = tracer != null && !(tracer instanceof NoOpTracerHook);
+		if (!hasRecorder && !hasTracer)
+			throw new InternalServerError(
+				"@Rest(observability=\"true\") is set on " + getResourceClass().getSimpleName()
+				+ " but no MetricsRecorder or TracerHook @Bean is registered. "
+				+ "Either wire a backend or change observability to \"\" (inherit) or \"false\" (opt-out).");
+	}
+
+	/**
+	 * Eagerly evaluates the {@link #asyncCompletionExecutor} memoizer when the annotation or programmatic
+	 * override is non-blank, ensuring "bean not found" surfaces as a startup failure (TODO-118).
+	 *
+	 * @throws IllegalStateException if the named executor bean is not in the bean store.
+	 */
+	private void checkAsyncCompletionExecutorPresent() {
+		var name = builder.asyncCompletionExecutorName != null
+			? builder.asyncCompletionExecutorName
+			: mergeReplacedStringAttribute(PROPERTY_asyncCompletionExecutor, null);
+		if (name != null && !name.isBlank())
+			asyncCompletionExecutor.get(); // throws IllegalStateException if bean is missing
+	}
+
 	private void initializeFrameworkBeansForRestOps() {
 		getMarshallingContext();
 		getEncoders();
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
index b892673e8e..0329657efb 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
@@ -53,6 +53,7 @@ import org.apache.juneau.httppart.bean.*;
 import org.apache.juneau.jsonschema.*;
 import org.apache.juneau.parser.*;
 import org.apache.juneau.parser.ParseException;
+import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.annotation.*;
 import org.apache.juneau.rest.common.utils.*;
 import org.apache.juneau.rest.converter.*;
@@ -678,6 +679,63 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
 		return -1L;
 	});
 
+	/**
+	 * Per-op async-completion executor: op-level bean name overrides {@code @Rest}-level, then both are
+	 * resolved against the resource's bean store. {@code null} when unset (natural completion thread).
+	 */
+	private final Memoizer<Executor> asyncCompletionExecutor = memoizer(() -> {
+		var v = findOpString(PROPERTY_asyncCompletionExecutor);
+		if (v.isPresent()) {
+			var name = v.get();
+			var bs = beanStore();
+			var resolved = bs.getBean(Executor.class, name);
+			if (resolved.isEmpty())
+				resolved = bs.getBean(ExecutorService.class, name).map(e -> (Executor) e);
+			if (resolved.isEmpty())
+				throw new IllegalStateException(
+					"@RestOp(asyncCompletionExecutor) on " + restContext().getResourceClass().getName()
+						+ "#" + method().getName() + " references bean name '" + name + "' but no Executor"
+						+ " (or ExecutorService) bean with that name is registered in the resource's bean store.");
+			return resolved.get();
+		}
+		if (isInherited(PROPERTY_asyncCompletionExecutor))
+			return restContext().getAsyncCompletionExecutor();
+		return null;
+	});
+
+	/**
+	 * Effective per-op observability state, resolving verb / {@code @RestOp} annotation override first,
+	 * then falling back to the resource-level {@code @Rest(observability)} attribute.
+	 *
+	 * <ul class='values'>
+	 * 	<li>{@code "true"} &mdash; strict opt-in; this operation requires a wired backend.
+	 * 	<li>{@code "false"} &mdash; explicit opt-out; the observability block is short-circuited.
+	 * 	<li>{@code null} &mdash; default inherit behavior; a missing backend silently falls back to NoOp.
+	 * </ul>
+	 */
+	private final Memoizer<String> observabilityAttr = memoizer(() -> {
+		var v = findOpString(PROPERTY_observability);
+		if (v.isPresent())
+			return v.get();
+		if (isInherited(PROPERTY_observability))
+			return restContext().getObservabilityAttribute();
+		return null;
+	});
+
+	/**
+	 * Per-op metric name override from {@code @RestOp(metricName)}.
+	 * Empty string (default) means the recorder uses its own default name derivation.
+	 */
+	private final Memoizer<String> metricName = memoizer(() ->
+		findOpString(PROPERTY_metricName).orElse(""));
+
+	/**
+	 * Per-op additional metric tags from {@code @RestOp(metricTags)}.
+	 * Format: comma-separated {@code key=value} pairs. Empty string (default) means no additional tags.
+	 */
+	private final Memoizer<String> metricTags = memoizer(() ->
+		findOpString(PROPERTY_metricTags).orElse(""));
+
 	/** Aggregated {@code noInherit} keys from all RestOp-group annotations on this operation. */
 	private final Memoizer<SortedSet<String>> noInheritOp = memoizer(() -> {
 		var l = getRestOpAnnotations().stream()
@@ -1088,6 +1146,32 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
 	 * @param attr The annotation attribute name (e.g. {@code "defaultCharset"}).
 	 * @return The resolved string, or empty if no annotation defines it.
 	 */
+	/**
+	 * Startup-fail: validates that when the effective observability attribute for this op is {@code "true"},
+	 * at least one real backend ({@link org.apache.juneau.rest.metrics.MetricsRecorder} or
+	 * {@link org.apache.juneau.rest.tracing.TracerHook}) is registered.
+	 *
+	 * <p>
+	 * Called once during construction.  The resource-level check in {@link RestContext#checkObservabilityBackendPresent()}
+	 * already fires for {@code @Rest(observability="true")}; this companion fires for individual
+	 * {@code @RestOp(observability="true")} overrides that elevate a single op into strict mode.
+	 */
+	private void checkOpObservabilityBackendPresent() {
+		var attr = observabilityAttr.get();
+		if (!"true".equalsIgnoreCase(attr))
+			return;
+		var bs = beanStore();
+		var recorder = bs.getBean(org.apache.juneau.rest.metrics.MetricsRecorder.class).orElse(null);
+		var tracer = bs.getBean(org.apache.juneau.rest.tracing.TracerHook.class).orElse(null);
+		boolean hasRecorder = recorder != null && !(recorder instanceof org.apache.juneau.rest.metrics.NoOpMetricsRecorder);
+		boolean hasTracer = tracer != null && !(tracer instanceof org.apache.juneau.rest.tracing.NoOpTracerHook);
+		if (!hasRecorder && !hasTracer)
+			throw new InternalServerError(
+				"@RestOp(observability=\"true\") is set on " + context.getResourceClass().getSimpleName() + "." + method.getName()
+				+ " but no MetricsRecorder or TracerHook @Bean is registered. "
+				+ "Either wire a backend or change observability to \"\" (inherit) or \"false\" (opt-out).");
+	}
+
 	private Optional<String> findOpString(String attr) {
 		var vr = varResolver();
 		for (var ai : getRestOpAnnotations()) {
@@ -1226,13 +1310,17 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
 			bs.add(HttpPartSerializer.class, getPartSerializer());
 			bs.add(SerializerSet.class, getSerializers());
 
-			// Inject @Value-annotated env-driven defaults onto this RestOpContext instance so that the
-			// memoizer lambdas (defaultCharset, maxInput) read fully resolved values when first invoked.
-			ClassInfo.of(this).inject(this, bs);
+		// Inject @Value-annotated env-driven defaults onto this RestOpContext instance so that the
+		// memoizer lambdas (defaultCharset, maxInput) read fully resolved values when first invoked.
+		ClassInfo.of(this).inject(this, bs);
+
+		// Startup-fail check: if this op (or its resource) declares observability="true" but neither
+		// MetricsRecorder nor TracerHook is wired, fail fast with a precise error.
+		checkOpObservabilityBackendPresent();
 
-			// The 6 formerly-eager scalar fields are now memoized; no eagerness needed here.
-			// Pre-warm httpMethod so it is in the memoizer cache for immediate use by compareTo/match.
-			httpMethod.get();
+		// The 6 formerly-eager scalar fields are now memoized; no eagerness needed here.
+		// Pre-warm httpMethod so it is in the memoizer cache for immediate use by compareTo/match.
+		httpMethod.get();
 
 			var pm = getPathMatchers();
 			bs.add(UrlPathMatcher[].class, pm);
@@ -1464,6 +1552,43 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
 	 */
 	public boolean isVirtualThreadsEnabled() { return virtualThreadsEnabled.get(); }
 
+	/**
+	 * Returns whether the observability block is enabled for this operation.
+	 *
+	 * <p>
+	 * Returns {@code false} when the effective resolved attribute is {@code "false"} (explicit opt-out).
+	 * Returns {@code true} in all other cases ({@code "true"} strict opt-in or empty/null default).
+	 *
+	 * <p>
+	 * Note: the default behavior (empty/null attribute) still runs the observability block, but silently
+	 * falls back to {@link org.apache.juneau.rest.metrics.NoOpMetricsRecorder} when no backend is wired.
+	 * Only {@code "false"} completely short-circuits the block.
+	 *
+	 * @return <jk>false</jk> when this operation has explicitly opted out of observability.
+	 * @see RestOp#observability()
+	 * @see Rest#observability()
+	 */
+	public boolean isObservabilityEnabled() {
+		var v = observabilityAttr.get();
+		return !"false".equalsIgnoreCase(v);
+	}
+
+	/**
+	 * Returns the per-op metric name override from {@code @RestOp(metricName)}.
+	 *
+	 * @return The metric name override, or an empty string when not set.
+	 * @see RestOp#metricName()
+	 */
+	public String getMetricName() { return metricName.get(); }
+
+	/**
+	 * Returns the per-op additional metric tags from {@code @RestOp(metricTags)}.
+	 *
+	 * @return The tags string (comma-separated {@code key=value} pairs), or empty string when not set.
+	 * @see RestOp#metricTags()
+	 */
+	public String getMetricTags() { return metricTags.get(); }
+
 	/**
 	 * Returns the configured async-response timeout (milliseconds) for this operation, or {@code -1} when no
 	 * value was supplied at the op or resource level — in which case the default 30-second fallback applies in
@@ -1473,6 +1598,16 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
 	 */
 	public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
 
+	/**
+	 * Returns the effective {@link java.util.concurrent.Executor} for routing
+	 * {@link java.util.concurrent.CompletableFuture} completion callbacks on this operation (TODO-118), or
+	 * {@code null} when neither the op nor the resource configures an executor (natural completion thread).
+	 *
+	 * @return The executor, or {@code null} if no async-completion executor is configured.
+	 * @since 9.5.0
+	 */
+	public Executor getAsyncCompletionExecutor() { return asyncCompletionExecutor.get(); }
+
 	/**
 	 * Returns the parsers to use for this method.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
index c86deeecc2..097429b78d 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
@@ -165,7 +165,11 @@ public class RestOpInvoker extends MethodInvoker {
 		long startNanos = 0L;
 		Throwable observed = null;
 		boolean observabilityDeferred = false;
-		if (observable) {
+		var opContext = opSession.getContext();
+		// observable=true means this is the @RestOp handler (not a pre/post call), AND observability is not
+		// explicitly disabled for this operation via @Rest(observability="false") or @RestOp(observability="false").
+		boolean effectivelyObservable = observable && opContext.isObservabilityEnabled();
+		if (effectivelyObservable) {
 			var bs = opSession.getRestContext().getBeanStore();
 			recorder = bs.getBean(MetricsRecorder.class).orElse(NoOpMetricsRecorder.INSTANCE);
 			var tracer = bs.getBean(TracerHook.class).orElse(NoOpTracerHook.INSTANCE);
@@ -190,9 +194,9 @@ public class RestOpInvoker extends MethodInvoker {
 			if (! inner().hasReturnType(Void.TYPE) && (nn(output) || ! res.getOutputStreamCalled()))
 				res.setContent(output);
 
-			if (observable && output instanceof CompletionStage<?> stage) {
+			if (effectivelyObservable && output instanceof CompletionStage<?> stage) {
 				observabilityDeferred = true;
-				deferObservability(stage, recorder, tracerScope, startNanos, opSession);
+				deferObservability(stage, recorder, tracerScope, startNanos, opSession, opContext.getMetricName(), opContext.getMetricTags());
 			}
 
 		} catch (IllegalAccessException | IllegalArgumentException e) {
@@ -204,7 +208,7 @@ public class RestOpInvoker extends MethodInvoker {
 			res.setStatus(500);  // May be overridden later.
 			res.setContent(opSession.getRestContext().convertThrowable(e2));
 		} finally {
-			if (observable && ! observabilityDeferred) {
+			if (effectivelyObservable && ! observabilityDeferred) {
 				int status = res.getStatus();
 				if (status == 0)
 					status = (observed == null) ? 200 : 500;
@@ -216,9 +220,9 @@ public class RestOpInvoker extends MethodInvoker {
 					try {
 						tracerScope.close();
 					} finally {
-						var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
-						var pathTemplate = resolveUriTemplate(opSession);
-						recorder.record(getFullName(), req.getMethod(), pathTemplate, status, elapsed, observed);
+					var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+					var pathTemplate = resolveUriTemplate(opSession);
+					recorder.record(getFullName(), req.getMethod(), pathTemplate, status, elapsed, observed, opContext.getMetricName(), opContext.getMetricTags());
 					}
 				}
 			}
@@ -226,7 +230,7 @@ public class RestOpInvoker extends MethodInvoker {
 	}
 
 	private void deferObservability(CompletionStage<?> stage, MetricsRecorder recorder, Scope tracerScope,
-			long startNanos, RestOpSession opSession) {
+			long startNanos, RestOpSession opSession, String metricName, String metricTags) {
 		var fullName = getFullName();
 		var httpMethod = opSession.getRequest().getMethod();
 		var pathTemplate = resolveUriTemplate(opSession);
@@ -241,8 +245,8 @@ public class RestOpInvoker extends MethodInvoker {
 				try {
 					tracerScope.close();
 				} finally {
-					var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
-					recorder.record(fullName, httpMethod, pathTemplate, status, elapsed, err);
+				var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+				recorder.record(fullName, httpMethod, pathTemplate, status, elapsed, err, metricName, metricTags);
 				}
 			}
 		});
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 221cb485ff..23263fb722 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -83,6 +83,14 @@ public final class RestServerConstants {
 	 */
 	public static final String PROPERTY_asyncTimeoutMillis = "asyncTimeoutMillis";
 
+	/**
+	 * The {@code "asyncCompletionExecutor"} annotation attribute name — names a {@link java.util.concurrent.Executor}
+	 * bean (resolved from the resource's {@link org.apache.juneau.commons.inject.BeanStore}) that routes
+	 * {@link java.util.concurrent.CompletableFuture} completion callbacks through a dedicated thread pool instead of
+	 * the future's natural completion thread (TODO-118). Empty string (default) = no override.
+	 */
+	public static final String PROPERTY_asyncCompletionExecutor = "asyncCompletionExecutor";
+
 	/** The {@code "clientVersionHeader"} annotation attribute name — used in {@code noInherit} matching. */
 	public static final String PROPERTY_clientVersionHeader = "clientVersionHeader";
 
@@ -212,6 +220,35 @@ public final class RestServerConstants {
 	/** The {@code "value"} annotation attribute name — used by {@code @RestOp}/verb annotations to hold the (optional method-prefixed) path; folded into {@link #PROPERTY_path}. */
 	public static final String PROPERTY_value = "value";
 
+	/**
+	 * The {@code "observability"} annotation attribute name — tri-state per-resource / per-op observability control.
+	 * {@code "true"} opts the resource in and requires a wired backend (startup-fail if missing); {@code "false"} short-circuits
+	 * the observability block entirely; {@code ""} (default) inherits / uses the existing silent-no-op behavior.
+	 *
+	 * @see org.apache.juneau.rest.annotation.Rest#observability()
+	 * @see org.apache.juneau.rest.annotation.RestOp#observability()
+	 */
+	public static final String PROPERTY_observability = "observability";
+
+	/**
+	 * The {@code "metricName"} annotation attribute name — per-op override for the metric name passed to
+	 * {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}. Empty string (default) uses
+	 * the recorder's own default name derivation.
+	 *
+	 * @see org.apache.juneau.rest.annotation.RestOp#metricName()
+	 */
+	public static final String PROPERTY_metricName = "metricName";
+
+	/**
+	 * The {@code "metricTags"} annotation attribute name — per-op additional metric tags passed to
+	 * {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}. Format: comma-separated
+	 * {@code key=value} pairs (e.g. {@code "team=payments,region=us-east"}). Empty string (default) means
+	 * no additional tags.
+	 *
+	 * @see org.apache.juneau.rest.annotation.RestOp#metricTags()
+	 */
+	public static final String PROPERTY_metricTags = "metricTags";
+
 	/**
 	 * Servlet-request attribute key under which the per-request id minted or honored by
 	 * {@code org.apache.juneau.rest.filter.RequestIdFilter} is stashed.
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
index f6f7d1521c..22f911bf08 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
@@ -741,6 +741,47 @@ public @interface Rest {
 	 */
 	String virtualThreads() default "";
 
+	/**
+	 * Per-resource observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Controls whether the FINISHED-67 observability block ({@link org.apache.juneau.rest.metrics.MetricsRecorder} /
+	 * {@link org.apache.juneau.rest.tracing.TracerHook}) fires for operations on this resource.
+	 * Per-operation overrides are available via {@link RestOp#observability()} (and the verb annotations).
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in: the resource <em>requires</em> a wired observability backend.
+	 * 		If neither a {@code @Bean MetricsRecorder} nor a {@code @Bean TracerHook} is registered when the
+	 * 		{@link org.apache.juneau.rest.RestContext} is built, construction fails with a precise error.
+	 * 		All operations on this resource have observability enabled.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out: the observability block is short-circuited for every
+	 * 		operation on this resource, even when a wired backend is present. Also suppresses
+	 * 		{@link org.apache.juneau.rest.tracing.TraceContextResponseProcessor} header injection for this resource.
+	 * 	<li><js>""</js> (default) &mdash; inherits the existing behavior: the observability block runs per-op,
+	 * 		but a missing {@code @Bean} silently falls back to the NoOp singleton — no startup error.
+	 * </ul>
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>
+	 * 		(e.g. <js>"$E{ENABLE_OBSERVABILITY,false}"</js>).
+	 * 	<li class='note'>
+	 * 		Per-operation overrides via {@link RestOp#observability()} (or verb annotations) take precedence over
+	 * 		this resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * 	<li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+	 * 	<li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+	 * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server &mdash; Observability</a>
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
 	/**
 	 * Configurable timeout (milliseconds) applied to {@link java.util.concurrent.CompletableFuture}-returning
 	 * handlers by {@link org.apache.juneau.rest.processor.AsyncResponseProcessor}. Default is 30,000 ms.
@@ -769,6 +810,59 @@ public @interface Rest {
 	 */
 	String asyncTimeoutMillis() default "";
 
+	/**
+	 * Names the {@link java.util.concurrent.Executor} bean used to route {@link java.util.concurrent.CompletableFuture}
+	 * completion callbacks through a dedicated thread pool.
+	 *
+	 * <p>
+	 * When set, the {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} switches from
+	 * {@code future.whenComplete(callback)} to {@code future.whenCompleteAsync(callback, executor)}, so the
+	 * response-handler work runs on the named pool instead of the future's natural completion thread (often an
+	 * I/O-driver or database-callback thread).
+	 *
+	 * <p>
+	 * The value is the {@link org.apache.juneau.commons.inject.Bean#name() @Bean(name=...)} lookup key for an
+	 * {@link java.util.concurrent.Executor} registered in the resource's bean store.  Empty string (default)
+	 * means no override — completion callbacks run on the future's natural thread (the existing behavior).
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<ja>@Rest</ja>(path=<js>"/api"</js>, asyncCompletionExecutor=<js>"myCompletionPool"</js>)
+	 * 	<jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+	 *
+	 * 		<ja>@Bean</ja>(name=<js>"myCompletionPool"</js>)
+	 * 		<jk>public</jk> Executor completionPool() {
+	 * 			<jk>return</jk> Executors.newFixedThreadPool(8);
+	 * 		}
+	 *
+	 * 		<ja>@RestGet</ja>(<js>"/orders/{id}"</js>)
+	 * 		<jk>public</jk> CompletableFuture&lt;Order&gt; getOrder(<ja>@Path</ja> String id) {
+	 * 			<jk>return</jk> orderService.fetchAsync(id); <jc>// callback runs on myCompletionPool</jc>
+	 * 		}
+	 * 	}
+	 * </p>
+	 *
+	 * <p>
+	 * Per-operation overrides are available via {@link RestOp#asyncCompletionExecutor()} (and the verb annotations).
+	 * A reference to a bean name that does not resolve in the bean store causes a startup failure.
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		MDC propagation (TODO-117) still works when this executor is set — the
+	 * 		{@link org.apache.juneau.rest.processor.MdcAsyncListener} wraps the callback <em>before</em> it is
+	 * 		routed through the executor, so the MDC snapshot is restored on whichever thread the executor picks.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='jc'>{@link org.apache.juneau.rest.processor.AsyncResponseProcessor}
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
 	/**
 	 * Specifies the compression encoders for this resource.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
index deba03a0c8..3b7c4a1777 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
@@ -91,9 +91,11 @@ public class RestAnnotation {
 		private String messages = "";
 		private String path = "";
 		private String[] paths = {};
+		private String observability = "";
 		private String problemDetails = "";
 		private String virtualThreads = "";
 		private String asyncTimeoutMillis = "";
+		private String asyncCompletionExecutor = "";
 		private String renderResponseStackTraces = "";
 		private String roleGuard = "";
 		private String rolesDeclared = "";
@@ -520,6 +522,17 @@ public class RestAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link Rest#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link Rest#virtualThreads()} property on this annotation.
 		 *
@@ -542,6 +555,17 @@ public class RestAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link Rest#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link Rest#renderResponseStackTraces()} property on this annotation.
 		 *
@@ -760,9 +784,11 @@ public class RestAnnotation {
 		private final String messages;
 		private final String path;
 		private final String[] paths;
+		private final String observability;
 		private final String problemDetails;
 		private final String virtualThreads;
 		private final String asyncTimeoutMillis;
+		private final String asyncCompletionExecutor;
 		private final String renderResponseStackTraces;
 		private final String roleGuard;
 		private final String rolesDeclared;
@@ -819,9 +845,11 @@ public class RestAnnotation {
 			partSerializer = b.partSerializer;
 			path = b.path;
 			paths = copyOf(b.paths);
+			observability = b.observability;
 			problemDetails = b.problemDetails;
 			virtualThreads = b.virtualThreads;
 			asyncTimeoutMillis = b.asyncTimeoutMillis;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
 			produces = copyOf(b.produces);
 			renderResponseStackTraces = b.renderResponseStackTraces;
 			responseProcessors = copyOf(b.responseProcessors);
@@ -1025,6 +1053,11 @@ public class RestAnnotation {
 			return queryParams;
 		}
 
+		@Override /* Overridden from Rest */
+		public String observability() {
+			return observability;
+		}
+
 		@Override /* Overridden from Rest */
 		public String problemDetails() {
 			return problemDetails;
@@ -1040,6 +1073,11 @@ public class RestAnnotation {
 			return asyncTimeoutMillis;
 		}
 
+		@Override /* Overridden from Rest */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
 		@Override /* Overridden from Rest */
 		public String renderResponseStackTraces() {
 			return renderResponseStackTraces;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
index 404652174f..cfbbe85ab9 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
@@ -445,6 +445,70 @@ public @interface RestDelete {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Allowed serializer session option keys for this operation (ordered merge, prefix {@code -key} removes a key).
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
index 91b65bd17f..159b304d9b 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
@@ -61,6 +61,10 @@ public class RestDeleteAnnotation {
 		private String debug = "";
 		private String defaultAccept = "";
 		private String defaultCharset = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -248,6 +252,50 @@ public class RestDeleteAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestDelete#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestDelete#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestDelete#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestDelete#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestDelete#allowedSerializerOptions()} property on this annotation.
 		 *
@@ -357,6 +405,10 @@ public class RestDeleteAnnotation {
 		private final String debug;
 		private final String defaultAccept;
 		private final String defaultCharset;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -389,6 +441,10 @@ public class RestDeleteAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			roleGuard = b.roleGuard;
 			rolesDeclared = b.rolesDeclared;
@@ -472,6 +528,26 @@ public class RestDeleteAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestDelete */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestDelete */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestDelete */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestDelete */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestDelete */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
index 475f17bb52..a43e62d556 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
@@ -463,6 +463,70 @@ public @interface RestGet {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
index a8f818a011..303234d0ec 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
@@ -65,6 +65,10 @@ public class RestGetAnnotation {
 		private String debug = "";
 		private String defaultAccept = "";
 		private String defaultCharset = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -265,6 +269,50 @@ public class RestGetAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestGet#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestGet#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestGet#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestGet#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestGet#produces()} property on this annotation.
 		 *
@@ -400,6 +448,10 @@ public class RestGetAnnotation {
 		private final String debug;
 		private final String defaultAccept;
 		private final String defaultCharset;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -434,6 +486,10 @@ public class RestGetAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			produces = copyOf(b.produces);
 			roleGuard = b.roleGuard;
@@ -524,6 +580,26 @@ public class RestGetAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestGet */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestGet */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestGet */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestGet */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestGet */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
index 3d3a394c00..38f85cc3e8 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
@@ -720,6 +720,87 @@ public @interface RestOp {
 	 */
 	String virtualThreads() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 * Controls whether the FINISHED-67 observability block ({@link org.apache.juneau.rest.metrics.MetricsRecorder} /
+	 * {@link org.apache.juneau.rest.tracing.TracerHook}) fires for this operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in: this operation <em>requires</em> a wired observability backend.
+	 * 		If neither a {@code @Bean MetricsRecorder} nor a {@code @Bean TracerHook} is registered when the
+	 * 		{@link org.apache.juneau.rest.RestContext} is built, construction fails with a precise error.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out: the observability block is short-circuited for this operation,
+	 * 		even when a wired backend is present. Also suppresses
+	 * 		{@link org.apache.juneau.rest.tracing.TraceContextResponseProcessor} header injection.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level {@link Rest#observability()} setting.
+	 * 		If the resource-level setting is also empty, uses the existing behavior: the observability block
+	 * 		runs per-op, but a missing backend silently falls back to the NoOp singleton.
+	 * </ul>
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>
+	 * 		(e.g. <js>"$E{ENABLE_OP_OBSERVABILITY,false}"</js>).
+	 * 	<li class='note'>
+	 * 		This per-operation setting takes full precedence over {@link Rest#observability()}.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link Rest#observability()}
+	 * 	<li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+	 * 	<li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation metric name override.
+	 *
+	 * <p>
+	 * Overrides the metric name passed to {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}
+	 * for this operation.  When empty (default), the recorder uses its own default name derivation (typically the
+	 * fully-qualified operation name).
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>.
+	 * 	<li class='note'>
+	 * 		Has no effect when observability is disabled for this operation.
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags.
+	 *
+	 * <p>
+	 * Additional metric tags to pass to {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}.
+	 * Format: comma-separated {@code key=value} pairs, e.g. {@code "team=payments,region=us-east"}.
+	 * When empty (default), no additional tags are passed.
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>.
+	 * 	<li class='note'>
+	 * 		The recorder implementation is responsible for parsing the comma-separated {@code key=value} string.
+	 * 	<li class='note'>
+	 * 		Has no effect when observability is disabled for this operation.
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Per-operation override for {@link Rest#asyncTimeoutMillis() @Rest(asyncTimeoutMillis)}.
 	 *
@@ -740,6 +821,23 @@ public @interface RestOp {
 	 */
 	String asyncTimeoutMillis() default "";
 
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
index 1477b02416..a44520633f 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
@@ -71,9 +71,13 @@ public class RestOpAnnotation {
 		private String defaultContentType = "";
 		private String maxInput = "";
 		private String method = "";
+		private String observability = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String virtualThreads = "";
 		private String asyncTimeoutMillis = "";
+		private String asyncCompletionExecutor = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
 		private String summary = "";
@@ -352,6 +356,39 @@ public class RestOpAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestOp#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestOp#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestOp#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestOp#virtualThreads()} property on this annotation.
 		 *
@@ -374,6 +411,17 @@ public class RestOpAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestOp#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestOp#produces()} property on this annotation.
 		 *
@@ -512,9 +560,13 @@ public class RestOpAnnotation {
 		private final String defaultContentType;
 		private final String maxInput;
 		private final String method;
+		private final String observability;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String virtualThreads;
 		private final String asyncTimeoutMillis;
+		private final String asyncCompletionExecutor;
 		private final String rolesDeclared;
 		private final String roleGuard;
 		private final String summary;
@@ -553,9 +605,13 @@ public class RestOpAnnotation {
 			method = b.method;
 			parsers = copyOf(b.parsers);
 			path = copyOf(b.path);
+			observability = b.observability;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			virtualThreads = b.virtualThreads;
 			asyncTimeoutMillis = b.asyncTimeoutMillis;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
 			produces = copyOf(b.produces);
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			allowedParserOptions = copyOf(b.allowedParserOptions);
@@ -663,6 +719,21 @@ public class RestOpAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestOp */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestOp */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestOp */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestOp */
 		public String problemDetails() {
 			return problemDetails;
@@ -678,6 +749,11 @@ public class RestOpAnnotation {
 			return asyncTimeoutMillis;
 		}
 
+		@Override /* Overridden from RestOp */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
 		@Override /* Overridden from RestOp */
 		public String[] produces() {
 			return produces;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
index df6d1176cf..0b1939dd8d 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
@@ -462,6 +462,70 @@ public @interface RestOptions {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
index 9fe35f092e..2ca75e7e01 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
@@ -65,6 +65,10 @@ public class RestOptionsAnnotation {
 		private String debug = "";
 		private String defaultAccept = "";
 		private String defaultCharset = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -276,6 +280,50 @@ public class RestOptionsAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestOptions#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestOptions#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestOptions#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestOptions#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestOptions#allowedSerializerOptions()} property on this annotation.
 		 *
@@ -399,6 +447,10 @@ public class RestOptionsAnnotation {
 		private final String debug;
 		private final String defaultAccept;
 		private final String defaultCharset;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -433,6 +485,10 @@ public class RestOptionsAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			produces = copyOf(b.produces);
 			roleGuard = b.roleGuard;
@@ -523,6 +579,26 @@ public class RestOptionsAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestOptions */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestOptions */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestOptions */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestOptions */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestOptions */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
index 388c94168b..934788f62b 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
@@ -614,6 +614,70 @@ public @interface RestPatch {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
index e570af6312..272a98bf8c 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
@@ -67,6 +67,10 @@ public class RestPatchAnnotation {
 		private String defaultCharset = "";
 		private String defaultContentType = "";
 		private String maxInput = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPatchAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestPatch#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPatch#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPatch#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPatch#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestPatch#produces()} property on this annotation.
 		 *
@@ -462,6 +510,10 @@ public class RestPatchAnnotation {
 		private final String defaultCharset;
 		private final String defaultContentType;
 		private final String maxInput;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPatchAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			produces = copyOf(b.produces);
 			roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPatchAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestPatch */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestPatch */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestPatch */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestPatch */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestPatch */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
index 1dcb16cded..dc700aa49f 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
@@ -613,6 +613,70 @@ public @interface RestPost {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
index e5b528c3c8..641cdcf410 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
@@ -68,6 +68,10 @@ public class RestPostAnnotation {
 		private String defaultCharset = "";
 		private String defaultContentType = "";
 		private String maxInput = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPostAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestPost#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPost#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPost#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPost#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestPost#produces()} property on this annotation.
 		 *
@@ -462,6 +510,10 @@ public class RestPostAnnotation {
 		private final String defaultCharset;
 		private final String defaultContentType;
 		private final String maxInput;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPostAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			produces = copyOf(b.produces);
 			roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPostAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestPost */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestPost */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestPost */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestPost */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestPost */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
index 321974539a..4b88177c26 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
@@ -613,6 +613,70 @@ public @interface RestPut {
 	 */
 	String problemDetails() default "";
 
+	/**
+	 * Per-operation observability opt-in / opt-out control.
+	 *
+	 * <p>
+	 * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js> &mdash; strict opt-in; requires a wired observability backend.
+	 * 	<li><js>"false"</js> &mdash; explicit opt-out; short-circuits the observability block.
+	 * 	<li><js>""</js> (default) &mdash; inherits the resource-level setting.
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#observability()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String observability() default "";
+
+	/**
+	 * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+	 *
+	 * <p>
+	 * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+	 * specific operation through a dedicated thread pool. Empty string (default) inherits from
+	 * {@link Rest#asyncCompletionExecutor()}.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+	 * 	<li class='ja'>{@link Rest#asyncCompletionExecutor()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String asyncCompletionExecutor() default "";
+
+	/**
+	 * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricName()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricName() default "";
+
+	/**
+	 * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+	 * Empty string (default) means no additional tags.
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='ja'>{@link RestOp#metricTags()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 9.5.0
+	 */
+	String metricTags() default "";
+
 	/**
 	 * Supported accept media types.
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
index edf10a160a..1c271b079e 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
@@ -68,6 +68,10 @@ public class RestPutAnnotation {
 		private String defaultCharset = "";
 		private String defaultContentType = "";
 		private String maxInput = "";
+		private String observability = "";
+		private String asyncCompletionExecutor = "";
+		private String metricName = "";
+		private String metricTags = "";
 		private String problemDetails = "";
 		private String rolesDeclared = "";
 		private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPutAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link RestPut#observability()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder observability(String value) {
+			observability = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPut#asyncCompletionExecutor()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder asyncCompletionExecutor(String value) {
+			asyncCompletionExecutor = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPut#metricName()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricName(String value) {
+			metricName = value;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link RestPut#metricTags()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder metricTags(String value) {
+			metricTags = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link RestPut#produces()} property on this annotation.
 		 *
@@ -462,6 +510,10 @@ public class RestPutAnnotation {
 		private final String defaultCharset;
 		private final String defaultContentType;
 		private final String maxInput;
+		private final String observability;
+		private final String asyncCompletionExecutor;
+		private final String metricName;
+		private final String metricTags;
 		private final String problemDetails;
 		private final String rolesDeclared;
 		private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPutAnnotation {
 			allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
 			noInherit = copyOf(b.noInherit);
 			path = copyOf(b.path);
+			observability = b.observability;
+			asyncCompletionExecutor = b.asyncCompletionExecutor;
+			metricName = b.metricName;
+			metricTags = b.metricTags;
 			problemDetails = b.problemDetails;
 			produces = copyOf(b.produces);
 			roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPutAnnotation {
 			return path;
 		}
 
+		@Override /* Overridden from RestPut */
+		public String observability() {
+			return observability;
+		}
+
+		@Override /* Overridden from RestPut */
+		public String asyncCompletionExecutor() {
+			return asyncCompletionExecutor;
+		}
+
+		@Override /* Overridden from RestPut */
+		public String metricName() {
+			return metricName;
+		}
+
+		@Override /* Overridden from RestPut */
+		public String metricTags() {
+			return metricTags;
+		}
+
 		@Override /* Overridden from RestPut */
 		public String problemDetails() {
 			return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
index 593a950f4d..f2bf205883 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
@@ -99,6 +99,12 @@ public interface MetricsRecorder {
 	 * @param error The exception thrown by the handler, or <jk>null</jk> if the call completed normally.
 	 * 	Bridges typically derive an {@code exception} tag from the throwable's simple class name, or
 	 * 	{@code "None"} when null (mirrors Spring Boot's {@code WebMvcMetricsFilter} convention).
+	 * @param metricName Per-op metric name override from {@link org.apache.juneau.rest.annotation.RestOp#metricName()}.
+	 * 	Empty string (default) means the implementation should use its own default name derivation.
+	 * 	Never <jk>null</jk>.
+	 * @param metricTags Per-op additional metric tags from {@link org.apache.juneau.rest.annotation.RestOp#metricTags()}.
+	 * 	Format: comma-separated {@code key=value} pairs (e.g. {@code "team=payments,region=us-east"}).
+	 * 	Empty string (default) means no additional tags. Never <jk>null</jk>.
 	 */
-	void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error);
+	void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags);
 }
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
index b1ea271350..6b01e72cfc 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
@@ -41,7 +41,7 @@ public final class NoOpMetricsRecorder implements MetricsRecorder {
 	private NoOpMetricsRecorder() {}
 
 	@Override /* MetricsRecorder */
-	public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+	public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
 		// Intentionally empty — the default off-by-default behaviour is to drop every event.
 	}
 }
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
index 8f4f6f56b6..a1911fb170 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
@@ -75,6 +75,14 @@ import org.apache.juneau.rest.*;
  * cooperation guarantees that {@link CompletableFuture} provides. Handlers that need async dispatch
  * must return {@link CompletableFuture} or {@link CompletionStage}.
  *
+ * <h5 class='section'>Completion executor</h5>
+ * <p>
+ * When {@code @Rest(asyncCompletionExecutor="poolName")} or the per-op equivalent is set, this processor
+ * switches from {@code future.whenComplete(callback)} to {@code future.whenCompleteAsync(callback, executor)},
+ * routing the response-handler work through the named {@link java.util.concurrent.Executor} bean. The MDC
+ * snapshot (see below) is taken on the request thread <em>before</em> the executor routing so the MDC context
+ * is always available regardless of which thread the executor picks (TODO-118).
+ *
  * <h5 class='section'>Thread-local caveats</h5>
  * <p>
  * Anything carried via {@link ThreadLocal} (SLF4J MDC, security contexts) does NOT survive the
@@ -208,9 +216,27 @@ public class AsyncResponseProcessor implements ResponseProcessor {
 			}
 		});
 
-		cf.whenComplete((value, error) ->
-			finalizeAsync(opSession, cf, asyncCtx, error, done, /* timeout */ false, timeoutMs, value)
+		// MDC bridge (TODO-117): snapshot request-thread MDC at dispatch time, then restore it on the
+		// completion thread so log statements inside the whenComplete callback see the same diagnostic
+		// context (requestId, userId, etc.) that was set by upstream filters.
+		// NOTE: the MDC wrap happens BEFORE the executor routing (TODO-118) so the snapshot is always
+		// available regardless of which thread the executor picks.
+		var mdcSnapshot = opSession.getRestContext().isMdcAsyncPropagation()
+			? MdcAsyncListener.snapshot()
+			: null;
+
+		var callback = MdcAsyncListener.wrap(
+			(value, error) -> finalizeAsync(opSession, cf, asyncCtx, error, done, /* timeout */ false, timeoutMs, value),
+			mdcSnapshot
 		);
+
+		// TODO-118: if a per-op or resource-level completion executor is configured, route the callback
+		// through it via whenCompleteAsync; otherwise use the natural completion thread.
+		var executor = opSession.getContext().getAsyncCompletionExecutor();
+		if (executor != null)
+			cf.whenCompleteAsync(callback, executor);
+		else
+			cf.whenComplete(callback);
 	}
 
 	private void finalizeAsync(RestOpSession opSession, CompletableFuture<?> cf, AsyncContext asyncCtx,
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java
new file mode 100644
index 0000000000..83956baf51
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java
@@ -0,0 +1,205 @@
+/*
+ * 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.juneau.rest.processor;
+
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+import java.util.logging.*;
+
+/**
+ * Bridges SLF4J MDC context from the request (dispatch) thread to the {@link java.util.concurrent.CompletableFuture}
+ * completion thread.
+ *
+ * <h5 class='topic'>Problem</h5>
+ * <p>
+ * When a {@code @RestOp} method returns a {@link java.util.concurrent.CompletableFuture}, the {@link AsyncResponseProcessor}
+ * registers a {@link java.util.concurrent.CompletionStage#whenComplete(BiConsumer) whenComplete} callback that runs on
+ * whatever thread completes the future &mdash; typically a different thread from the one that processed the request.
+ * SLF4J's {@code MDC} is backed by a {@link ThreadLocal}, so any diagnostic context set by an upstream filter
+ * (e.g. {@code requestId}, {@code userId}) is invisible to log statements emitted inside that callback.
+ *
+ * <h5 class='topic'>Solution</h5>
+ * <p>
+ * {@code MdcAsyncListener} provides a single factory method, {@link #wrap(BiConsumer, Map)}, that wraps a
+ * {@code whenComplete} callback with the standard MDC snapshot-restore-clear pattern:
+ * <ol>
+ *   <li>Snapshot the request thread's MDC map via {@code MDC.getCopyOfContextMap()} at dispatch time.
+ *   <li>On entry to the completion callback: save the completion thread's current MDC (if any) and install
+ *       the request-thread snapshot via {@code MDC.setContextMap(snapshot)}.
+ *   <li>Callback body runs with the request-thread MDC visible.
+ *   <li>In the {@code finally} block: restore the completion thread's original MDC state (if it had one) or
+ *       call {@code MDC.clear()} &mdash; this removes exactly the keys the listener installed without disturbing
+ *       any MDC state the completion thread itself had before the callback.
+ * </ol>
+ *
+ * <h5 class='topic'>Lazy skip</h5>
+ * <p>
+ * {@link #snapshot()} returns {@code null} when the request thread's MDC is empty or when SLF4J is absent.
+ * {@link #wrap(BiConsumer, Map)} returns the original callback unchanged when the snapshot is {@code null}, so no
+ * work is done on the completion thread for the common case of no MDC state.
+ *
+ * <h5 class='topic'>SLF4J detection</h5>
+ * <p>
+ * {@code MdcAsyncListener} uses {@link Class#forName(String)} to probe for {@code org.slf4j.MDC} at class-load
+ * time. SLF4J is NOT a compile-time dependency of {@code juneau-rest-server}. If SLF4J is absent from the
+ * runtime classpath, the class gracefully degrades: {@link #isAvailable()} returns {@code false},
+ * {@link #snapshot()} returns {@code null}, and {@link #wrap(BiConsumer, Map)} returns the original callback.
+ *
+ * <h5 class='topic'>Limitation</h5>
+ * <p>
+ * This class bridges SLF4J MDC only. Users of Log4j2's {@code ThreadContext} directly (without the SLF4J facade)
+ * will not benefit from this listener. That is documented as a v1 limitation; the SLF4J MDC is the lingua franca
+ * for structured logging context propagation in the JVM ecosystem.
+ *
+ * @see AsyncResponseProcessor
+ * @see org.apache.juneau.rest.RestContext#isMdcAsyncPropagation()
+ * @since 9.5.0
+ */
+public final class MdcAsyncListener {
+
+	private static final Logger LOG = Logger.getLogger(MdcAsyncListener.class.getName());
+
+	/** {@code true} if {@code org.slf4j.MDC} was found on the classpath at class-load time. */
+	private static final boolean AVAILABLE;
+
+	private static final Method GET_COPY_OF_CONTEXT_MAP;
+	private static final Method SET_CONTEXT_MAP;
+	private static final Method CLEAR;
+
+	static {
+		boolean available = false;
+		Method copyOf = null, set = null, clr = null;
+		try {
+			Class<?> mdc = Class.forName("org.slf4j.MDC");
+			copyOf = mdc.getMethod("getCopyOfContextMap");
+			set = mdc.getMethod("setContextMap", Map.class);
+			clr = mdc.getMethod("clear");
+			available = true;
+		} catch (Exception e) {
+			LOG.fine(() -> "SLF4J MDC not found on classpath — MdcAsyncListener is a no-op: " + e.getMessage());
+		}
+		AVAILABLE = available;
+		GET_COPY_OF_CONTEXT_MAP = copyOf;
+		SET_CONTEXT_MAP = set;
+		CLEAR = clr;
+	}
+
+	private MdcAsyncListener() {}
+
+	/**
+	 * Returns whether SLF4J MDC is available on the runtime classpath.
+	 *
+	 * <p>
+	 * When {@code false}, all methods on this class are no-ops.
+	 *
+	 * @return {@code true} if {@code org.slf4j.MDC} was found at class-load time.
+	 */
+	public static boolean isAvailable() {
+		return AVAILABLE;
+	}
+
+	/**
+	 * Takes a snapshot of the current thread's SLF4J MDC map.
+	 *
+	 * <p>
+	 * Call this on the request/dispatch thread before submitting async work. The returned map is passed to
+	 * {@link #wrap(BiConsumer, Map)} to decorate the completion callback.
+	 *
+	 * <p>
+	 * Returns {@code null} when:
+	 * <ul>
+	 *   <li>SLF4J is absent from the classpath, or
+	 *   <li>The current thread's MDC is {@code null} or empty (lazy-skip contract — no work is done on the
+	 *       completion thread when the request had no MDC context).
+	 * </ul>
+	 *
+	 * @return A copy of the current MDC map, or {@code null}.
+	 */
+	@SuppressWarnings("unchecked")
+	public static Map<String, String> snapshot() {
+		if (!AVAILABLE)
+			return null;
+		try {
+			Map<String, String> map = (Map<String, String>) GET_COPY_OF_CONTEXT_MAP.invoke(null);
+			return (map == null || map.isEmpty()) ? null : map;
+		} catch (Exception e) {
+			LOG.fine(() -> "MDC.getCopyOfContextMap() failed: " + e.getMessage());
+			return null;
+		}
+	}
+
+	/**
+	 * Wraps a {@link BiConsumer} (typically a {@link java.util.concurrent.CompletableFuture#whenComplete
+	 * whenComplete} callback) so the completion thread sees the request thread's MDC context.
+	 *
+	 * <p>
+	 * The wrapping logic:
+	 * <ol>
+	 *   <li>Save the completion thread's current MDC map (usually {@code null} for pool / virtual threads).
+	 *   <li>Install {@code requestMdc} on the completion thread via {@code MDC.setContextMap(requestMdc)}.
+	 *   <li>Invoke {@code action} inside a {@code try} block.
+	 *   <li>{@code finally}: if the completion thread had its own MDC, restore it; otherwise call
+	 *       {@code MDC.clear()} — either way only the keys the listener installed are removed.
+	 * </ol>
+	 *
+	 * <p>
+	 * If {@code requestMdc} is {@code null} (e.g. request thread had no MDC, or SLF4J absent), the original
+	 * {@code action} is returned unchanged — zero overhead on the completion thread.
+	 *
+	 * @param <T>        The future result type.
+	 * @param action     The callback to wrap.
+	 * @param requestMdc The MDC snapshot taken on the dispatch thread (from {@link #snapshot()}), or {@code null}.
+	 * @return A wrapped callback, or {@code action} itself when wrapping is not needed.
+	 */
+	public static <T> BiConsumer<T, Throwable> wrap(BiConsumer<T, Throwable> action, Map<String, String> requestMdc) {
+		if (!AVAILABLE || requestMdc == null)
+			return action;
+
+		return (value, error) -> {
+			// Save the completion thread's pre-existing MDC (so we can restore it, not wipe it).
+			var before = snapshot();
+			try {
+				setContextMap(requestMdc);
+				action.accept(value, error);
+			} finally {
+				if (before != null)
+					setContextMap(before);
+				else
+					mdcClear();
+			}
+		};
+	}
+
+	@SuppressWarnings("java:S3011") // Reflective access to MDC methods — intentional; SLF4J is not a compile dep.
+	static void setContextMap(Map<String, String> map) {
+		try {
+			SET_CONTEXT_MAP.invoke(null, map);
+		} catch (Exception e) {
+			LOG.fine(() -> "MDC.setContextMap() failed: " + e.getMessage());
+		}
+	}
+
+	@SuppressWarnings("java:S3011") // Reflective access to MDC methods — intentional.
+	static void mdcClear() {
+		try {
+			CLEAR.invoke(null);
+		} catch (Exception e) {
+			LOG.fine(() -> "MDC.clear() failed: " + e.getMessage());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java
new file mode 100644
index 0000000000..cf04f3f30a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java
@@ -0,0 +1,122 @@
+/*
+ * 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.juneau.rest.tracing;
+
+import java.io.*;
+import java.util.logging.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+
+/**
+ * Writes the W3C Trace Context response headers ({@code traceparent} and, when present,
+ * {@code tracestate}) so a client calling this server can read the resulting trace identifiers back
+ * off the response.
+ *
+ * <h5 class='topic'>How it works</h5>
+ *
+ * <p>
+ * A {@link TracerHook} bridge (e.g. the OpenTelemetry bridge in {@code juneau-rest-server-otel})
+ * renders the W3C {@code traceparent} / {@code tracestate} values from the <i>server-started</i> span
+ * context at {@link TracerHook#startSpan(RestRequest) span-start} time &mdash; the only point where
+ * that context is reliably active &mdash; and stashes them as the request attributes
+ * {@link #ATTR_TRACEPARENT} / {@link #ATTR_TRACESTATE}. This processor runs later, during response
+ * rendering (after the span scope has already closed), reads those stashed strings, and writes them
+ * as response headers.
+ *
+ * <p>
+ * This capture-at-span-start / write-at-response-time split is deliberate: the framework opens and
+ * closes the tracing scope around the {@code @RestOp} handler in
+ * {@link RestOpInvoker#invokeOp(RestOpSession)}, which completes <i>before</i> response processors
+ * run, so {@code Span.current()} is no longer valid here. Keeping the rendered values on the request
+ * also keeps this processor free of any OpenTelemetry (or other tracer) dependency &mdash; it works
+ * with any {@code TracerHook} bridge that populates the two attributes.
+ *
+ * <h5 class='topic'>Off-by-default behavior</h5>
+ *
+ * <p>
+ * The first action in {@link #process(RestOpSession)} is a single request-attribute read. When no
+ * non-no-op {@code TracerHook} ran for the request, no {@link #ATTR_TRACEPARENT} attribute is present
+ * and the processor returns {@link #NEXT} immediately with no allocations &mdash; matching the
+ * off-by-default, zero-cost contract of the observability hooks. Registration of this processor is
+ * itself gated on {@code RestContext.responseTraceparent} (default {@code true}); setting it to
+ * {@code false} keeps the processor out of the chain entirely.
+ *
+ * <h5 class='topic'>Already-committed responses</h5>
+ *
+ * <p>
+ * If the response has already been committed (for example a streaming / SSE handler that flushed its
+ * headers before returning), the headers can no longer be set. In that case the processor logs a
+ * {@link Level#FINE FINE} message and skips the write &mdash; it never throws.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link TracerHook}
+ * 	<li class='link'><a class="doclink" href="https://www.w3.org/TR/trace-context/">W3C Trace Context</a>
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class TraceContextResponseProcessor implements ResponseProcessor {
+
+	/**
+	 * Request-attribute key under which a {@link TracerHook} bridge stashes the rendered W3C
+	 * {@code traceparent} value (e.g. {@code 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01})
+	 * for this processor to emit as a response header.
+	 */
+	public static final String ATTR_TRACEPARENT = "juneau.traceparent";
+
+	/**
+	 * Request-attribute key under which a {@link TracerHook} bridge stashes the rendered W3C
+	 * {@code tracestate} value (a comma-separated {@code key=value} list). Only set when the active
+	 * trace state is non-empty.
+	 */
+	public static final String ATTR_TRACESTATE = "juneau.tracestate";
+
+	private static final String HEADER_TRACEPARENT = "traceparent";
+	private static final String HEADER_TRACESTATE = "tracestate";
+
+	private static final Logger LOG = Logger.getLogger(TraceContextResponseProcessor.class.getName());
+
+	@Override /* Overridden from ResponseProcessor */
+	public int process(RestOpSession opSession) throws IOException, BasicHttpException {
+		var req = opSession.getRequest();
+
+		// NoOp short-circuit: no active tracer stashed a trace context for this request.
+		var traceparent = req.getAttribute(ATTR_TRACEPARENT).as(String.class).orElse(null);
+		if (traceparent == null || traceparent.isEmpty())
+			return NEXT;
+
+		var res = opSession.getResponse();
+
+		// Headers already flushed (e.g. streaming / SSE response) — cannot set; skip without throwing.
+		if (res.getHttpServletResponse().isCommitted()) {
+			LOG.log(Level.FINE, () -> "Response already committed; skipping traceparent/tracestate header injection.");
+			return NEXT;
+		}
+
+		res.setHeader(HEADER_TRACEPARENT, traceparent);
+
+		var tracestate = req.getAttribute(ATTR_TRACESTATE).as(String.class).orElse(null);
+		if (tracestate != null && ! tracestate.isEmpty())
+			res.setHeader(HEADER_TRACESTATE, tracestate);
+
+		// Headers only — let the downstream processors render the body.
+		return NEXT;
+	}
+}
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index a6ae8eaf5b..ca60da7b71 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -39,6 +39,7 @@
 		<module>juneau-rest-server-jwt</module>
 		<module>juneau-rest-server-saml</module>
 		<module>juneau-rest-server-oauth</module>
+		<module>juneau-rest-server-oidc-rp</module>
 		<module>juneau-rest-server-micrometer</module>
 		<module>juneau-rest-server-otel</module>
 		<module>juneau-rest-server-view-jsp</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index bd4ca16572..1a30065ec0 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -297,6 +297,17 @@
 			<version>11.37.2</version>
 			<scope>test</scope>
 		</dependency>
+		<!--
+			juneau-rest-server-oidc-rp (OpenID Connect Relying Party login).  Nimbus is `provided` on
+			the module's POM, so the upstream tree never sees it; juneau-utest pulls it explicitly in
+			`test` scope to drive the end-to-end stub-IdP tests.
+		-->
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-server-oidc-rp</artifactId>
+			<version>${project.version}</version>
+			<scope>test</scope>
+		</dependency>
 		<dependency>
 			<groupId>org.apache.juneau</groupId>
 			<artifactId>juneau-rest-server-springboot</artifactId>
diff --git a/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java b/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java
new file mode 100644
index 0000000000..49f0c1739c
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java
@@ -0,0 +1,165 @@
+/*
+ * 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.juneau.bean;
+
+import static org.apache.juneau.TestUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.bean.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for materialization of abstract {@link Collection} bean properties that have no setter and no
+ * {@code @BeanProp(type=...)} override.
+ *
+ * <p>
+ * Such fields are populated from a JSON array source by
+ * {@code BeanPropertyMeta.setPropertyValue(...)}, which instantiates a concrete collection via
+ * {@code createDefaultCollectionForAbstractType(...)}.  These tests pin the shape-based default table
+ * ({@code Set}&rarr;{@code LinkedHashSet}, {@code SortedSet}&rarr;{@code TreeSet},
+ * {@code List}/raw {@code Collection}&rarr;{@code ArrayList}, {@code Deque}/{@code Queue}&rarr;{@code ArrayDeque}),
+ * the still-honored {@code @BeanProp(type=...)} override, and the best-effort-materialize-then-fallback
+ * behavior for custom abstract-set subclasses.
+ */
+class BeanPropertyMeta_AbstractSet_Test extends TestBase {
+
+	public enum Tag { URGENT, PRIORITY, LOW }
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a01: abstract Set<EnumType> field, no setter, no @BeanProp -> LinkedHashSet (order-preserving), elements coerced.
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A01_Bean { public Set<Tag> tags; }
+
+	@Test void a01_setOfEnum_materializesLinkedHashSet() {
+		var a = json("{tags:['URGENT','PRIORITY']}", A01_Bean.class);
+		assertInstanceOf(LinkedHashSet.class, a.tags);
+		assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags));  // Insertion order preserved.
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a02: abstract SortedSet<EnumType> field -> TreeSet (natural ordering of the enum).
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A02_Bean { public SortedSet<Tag> tags; }
+
+	@Test void a02_sortedSetOfEnum_materializesTreeSet() {
+		var a = json("{tags:['PRIORITY','URGENT','LOW']}", A02_Bean.class);
+		assertInstanceOf(TreeSet.class, a.tags);
+		// TreeSet orders by enum ordinal: URGENT(0), PRIORITY(1), LOW(2).
+		assertEquals(List.of(Tag.URGENT, Tag.PRIORITY, Tag.LOW), new ArrayList<>(a.tags));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a03: abstract List<EnumType> field, no setter -> ArrayList.
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A03_Bean { public List<Tag> tags; }
+
+	@Test void a03_listOfEnum_materializesArrayList() {
+		var a = json("{tags:['LOW','URGENT']}", A03_Bean.class);
+		assertInstanceOf(ArrayList.class, a.tags);
+		assertEquals(List.of(Tag.LOW, Tag.URGENT), a.tags);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a04: abstract Deque<String> field -> ArrayDeque.
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A04_Bean { public Deque<String> items; }
+
+	@Test void a04_dequeOfString_materializesArrayDeque() {
+		var a = json("{items:['x','y']}", A04_Bean.class);
+		assertInstanceOf(ArrayDeque.class, a.items);
+		assertEquals(List.of("x", "y"), new ArrayList<>(a.items));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a05: @BeanProp(type=TreeSet.class) override still honored (wins over the LinkedHashSet shape default for Set).
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A05_Bean {
+		@BeanProp(type=TreeSet.class, params={Tag.class})
+		public Set<Tag> tags;
+	}
+
+	@Test void a05_beanPropTypeOverride_honored() {
+		var a = json("{tags:['PRIORITY','URGENT']}", A05_Bean.class);
+		assertInstanceOf(TreeSet.class, a.tags);  // Override wins; not the LinkedHashSet default.
+		// params={Tag.class} carries the element type, so elements are coerced and TreeSet orders by enum ordinal.
+		assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a06: OQA-4 - a custom *instantiable* AbstractSet subclass (public no-arg ctor) is materialized as ITSELF.
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A06_InstantiableSet<E> extends AbstractSet<E> {
+		private final Set<E> delegate = new LinkedHashSet<>();
+		@Override public boolean add(E e) { return delegate.add(e); }
+		@Override public Iterator<E> iterator() { return delegate.iterator(); }
+		@Override public int size() { return delegate.size(); }
+	}
+
+	public static class A06_Bean { public A06_InstantiableSet<Tag> tags; }
+
+	@Test void a06_instantiableCustomAbstractSet_materializedAsItself() {
+		var a = json("{tags:['URGENT','LOW']}", A06_Bean.class);
+		assertInstanceOf(A06_InstantiableSet.class, a.tags);
+		assertEquals(List.of(Tag.URGENT, Tag.LOW), new ArrayList<>(a.tags));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a07: OQA-4 - a genuinely *non-instantiable* abstract set field (java.util.AbstractSet) falls back to
+	//      LinkedHashSet (the Set shape default; LinkedHashSet is-a AbstractSet, so the field assignment succeeds).
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A07_Bean { public AbstractSet<Tag> tags; }
+
+	@Test void a07_nonInstantiableAbstractSet_fallsBackToLinkedHashSet() {
+		var a = json("{tags:['URGENT','PRIORITY']}", A07_Bean.class);
+		assertInstanceOf(LinkedHashSet.class, a.tags);
+		assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a08: abstract Queue<String> field -> ArrayDeque (Queue shape default).
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A08_Bean { public Queue<String> items; }
+
+	@Test void a08_queueOfString_materializesArrayDeque() {
+		var a = json("{items:['a','b']}", A08_Bean.class);
+		assertInstanceOf(ArrayDeque.class, a.items);
+		assertEquals(List.of("a", "b"), new ArrayList<>(a.items));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a09: raw abstract Collection field -> ArrayList (raw Collection default).
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class A09_Bean { public Collection<String> items; }
+
+	@Test void a09_rawCollection_materializesArrayList() {
+		var a = json("{items:['p','q']}", A09_Bean.class);
+		assertInstanceOf(ArrayList.class, a.items);
+		assertEquals(List.of("p", "q"), new ArrayList<>(a.items));
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
index 58f82731ce..86de382689 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
@@ -54,7 +54,7 @@ class Value_Test extends TestBase {
 		var s = Settings.get();
 		for (var k : List.of(P_STRING, P_INT, P_BOOL, P_URI, P_INSTANT, "Value_Test.optional",
 			"Value_Test.timeout.ms", "Value_Test.field", "Value_Test.setter", "Value_Test.springStyle",
-			"Value_Test.coerceFail"))
+			"Value_Test.coerceFail", "Value_Test.optionalSetter", "Value_Test.optionalCtor"))
 			s.unsetGlobal(k);
 	}
 
@@ -135,6 +135,30 @@ class Value_Test extends TestBase {
 		}
 	}
 
+	/** Setter that accepts an {@code Optional<String>} @Value parameter — worked example of the TODO-128 fix. */
+	public static class OptionalSetterBean {
+		Optional<String> maybe;
+
+		@Inject
+		public void setMaybe(@Value("${Value_Test.optionalSetter}") Optional<String> maybe) {
+			this.maybe = maybe;
+		}
+	}
+
+	/** Constructor that mixes a plain and an Optional @Value parameter — worked example of the TODO-128 fix. */
+	public static class OptionalConstructorBean {
+		final String plain;
+		final Optional<String> maybe;
+
+		@Inject
+		public OptionalConstructorBean(
+				@Value("${Value_Test.string:hello}") String plain,
+				@Value("${Value_Test.optionalCtor}") Optional<String> maybe) {
+			this.plain = plain;
+			this.maybe = maybe;
+		}
+	}
+
 	public static class ConflictFieldBean {
 		// Mutual-exclusion guard fires when both are present on the same site.
 		@Value("${Value_Test.field}")
@@ -275,6 +299,42 @@ class Value_Test extends TestBase {
 		assertEquals("from-settings", bean.field);
 	}
 
+	// TODO-128 worked example: Optional<T> setter parameter previously threw IAE at reflective invoke.
+	@Test
+	void c03_setter_optionalParam_missing_isEmpty() {
+		var bean = BeanInstantiator.of(OptionalSetterBean.class, beanStore).run();
+		assertNotNull(bean.maybe);
+		assertTrue(bean.maybe.isEmpty());
+	}
+
+	@Test
+	void c04_setter_optionalParam_present() {
+		Settings.get().setGlobal("Value_Test.optionalSetter", "setter-opt");
+		var bean = BeanInstantiator.of(OptionalSetterBean.class, beanStore).run();
+		assertNotNull(bean.maybe);
+		assertTrue(bean.maybe.isPresent());
+		assertEquals("setter-opt", bean.maybe.get());
+	}
+
+	// TODO-128 worked example: Optional<T> constructor parameter.
+	@Test
+	void b03_constructorParam_optionalMissing_isEmpty() {
+		var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+		assertEquals("hello", bean.plain);
+		assertNotNull(bean.maybe);
+		assertTrue(bean.maybe.isEmpty());
+	}
+
+	@Test
+	void b04_constructorParam_optionalPresent() {
+		Settings.get().setGlobal("Value_Test.optionalCtor", "ctor-opt");
+		var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+		assertEquals("hello", bean.plain);
+		assertNotNull(bean.maybe);
+		assertTrue(bean.maybe.isPresent());
+		assertEquals("ctor-opt", bean.maybe.get());
+	}
+
 	//====================================================================================================
 	// Mutual-exclusion with @Inject.
 	//====================================================================================================
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
index b3e961aada..254a04f9b1 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
@@ -623,4 +623,24 @@ class Logger_Test extends TestBase {
 			assertSize(1, capture2.getRecords()); // Only new message
 		}
 	}
+
+	//====================================================================================================
+	// Registry stability under high logger-name volume
+	// Regression for the Java-25 Q_logging failure: a Cache-backed registry cleared itself once
+	// 1000+ distinct names existed, causing getLogger(X) to return a new instance for a name
+	// whose class had pinned the old instance in a static final field.
+	//====================================================================================================
+
+	@Test void h01_registryStable_afterManyDistinctNames() {
+		// Pin the instance before flooding the registry.
+		var pinned = Logger.getLogger("h01.pinned");
+
+		// Create enough distinct logger names to exceed the old Cache maxSize threshold (1000).
+		for (int i = 0; i < 1500; i++)
+			Logger.getLogger("h01.flood." + i);
+
+		// The registry must still return the exact same object for the original name.
+		assertSame(pinned, Logger.getLogger("h01.pinned"),
+			"Logger registry must be stable: getLogger(name) must always return the same instance");
+	}
 }
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java
new file mode 100644
index 0000000000..160c16000b
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java
@@ -0,0 +1,184 @@
+/*
+ * 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.juneau.commons.reflect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.settings.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Acceptance tests for TODO-128: {@code @Value Optional<T>} wrapping parity between
+ * {@link ParameterInfo#resolveValue} and {@link FieldInfo#inject}.
+ *
+ * <p>
+ * Prior to this fix, declaring a parameter as {@code @Value("...") Optional<String>} caused
+ * {@link IllegalArgumentException} at reflective invoke because {@link ParameterInfo#resolveValue}
+ * returned the unwrapped scalar instead of an {@link Optional}.
+ */
+class ParameterInfo_OptionalValue_Test extends TestBase {
+
+	private static final String KEY = "ParameterInfo_OptionalValue_Test.key";
+
+	private BasicBeanStore beanStore;
+
+	@BeforeEach
+	void setUp() {
+		beanStore = new BasicBeanStore(null);
+	}
+
+	@AfterEach
+	void cleanup() {
+		Settings.get().unsetGlobal(KEY);
+	}
+
+	//====================================================================================================
+	// Fixtures
+	//====================================================================================================
+
+	public static class OptionalMethodBean {
+		Optional<String> result;
+
+		@Inject
+		public void inject(@Value("${" + KEY + "}") Optional<String> value) {
+			this.result = value;
+		}
+	}
+
+	public static class OptionalConstructorBean {
+		final Optional<String> value;
+
+		@Inject
+		public OptionalConstructorBean(@Value("${" + KEY + "}") Optional<String> value) {
+			this.value = value;
+		}
+	}
+
+	public static class PlainStringMethodBean {
+		String result;
+
+		@Inject
+		public void inject(@Value("${" + KEY + ":plain-default}") String value) {
+			this.result = value;
+		}
+	}
+
+	/** Mixed: one Optional, one plain String — verifies per-parameter wrapping. */
+	public static class MixedMethodBean {
+		Optional<String> optional;
+		String plain;
+
+		@Inject
+		public void inject(
+				@Value("${" + KEY + "}") Optional<String> optional,
+				@Value("${" + KEY + ":plain-default}") String plain) {
+			this.optional = optional;
+			this.plain = plain;
+		}
+	}
+
+	//====================================================================================================
+	// Scenario 1: @Value Optional<String> method parameter — resolved value present → Optional.of(...)
+	//====================================================================================================
+
+	@Test
+	void a01_optionalMethodParam_present() {
+		Settings.get().setGlobal(KEY, "hello");
+		var bean = BeanInstantiator.of(OptionalMethodBean.class, beanStore).run();
+		assertNotNull(bean.result, "result must not be null");
+		assertTrue(bean.result.isPresent(), "result must be present");
+		assertEquals("hello", bean.result.get());
+	}
+
+	//====================================================================================================
+	// Scenario 2: @Value Optional<String> method parameter — no key, no default → Optional.empty()
+	//====================================================================================================
+
+	@Test
+	void a02_optionalMethodParam_absent_isEmpty() {
+		var bean = BeanInstantiator.of(OptionalMethodBean.class, beanStore).run();
+		assertNotNull(bean.result, "result must not be null");
+		assertTrue(bean.result.isEmpty(), "result must be empty when key is absent");
+	}
+
+	//====================================================================================================
+	// Scenario 3: @Value Optional<String> constructor parameter — resolved value present → Optional.of(...)
+	//====================================================================================================
+
+	@Test
+	void a03_optionalConstructorParam_present() {
+		Settings.get().setGlobal(KEY, "world");
+		var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+		assertNotNull(bean.value, "value must not be null");
+		assertTrue(bean.value.isPresent(), "value must be present");
+		assertEquals("world", bean.value.get());
+	}
+
+	//====================================================================================================
+	// Scenario 4: @Value Optional<String> constructor parameter — absent → Optional.empty()
+	//====================================================================================================
+
+	@Test
+	void a04_optionalConstructorParam_absent_isEmpty() {
+		var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+		assertNotNull(bean.value, "value must not be null");
+		assertTrue(bean.value.isEmpty(), "value must be empty when key is absent");
+	}
+
+	//====================================================================================================
+	// Scenario 5: Plain String parameter (non-Optional) — unwrapped scalar, unchanged behavior
+	//====================================================================================================
+
+	@Test
+	void a05_plainStringParam_usesDefault() {
+		var bean = BeanInstantiator.of(PlainStringMethodBean.class, beanStore).run();
+		assertEquals("plain-default", bean.result);
+	}
+
+	@Test
+	void a05b_plainStringParam_fromSettings() {
+		Settings.get().setGlobal(KEY, "from-settings");
+		var bean = BeanInstantiator.of(PlainStringMethodBean.class, beanStore).run();
+		assertEquals("from-settings", bean.result);
+	}
+
+	//====================================================================================================
+	// Scenario 6: Mixed parameters (one Optional, one plain) — correct wrap per parameter
+	//====================================================================================================
+
+	@Test
+	void a06_mixedParams_optionalAbsent_plainDefault() {
+		var bean = BeanInstantiator.of(MixedMethodBean.class, beanStore).run();
+		assertNotNull(bean.optional, "optional must not be null");
+		assertTrue(bean.optional.isEmpty(), "optional must be empty when key is absent");
+		assertEquals("plain-default", bean.plain);
+	}
+
+	@Test
+	void a07_mixedParams_optionalPresent_plainFromSettings() {
+		Settings.get().setGlobal(KEY, "mixed-value");
+		var bean = BeanInstantiator.of(MixedMethodBean.class, beanStore).run();
+		assertNotNull(bean.optional, "optional must not be null");
+		assertTrue(bean.optional.isPresent(), "optional must be present");
+		assertEquals("mixed-value", bean.optional.get());
+		assertEquals("mixed-value", bean.plain);
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
index b7ab2244b1..21aff98c1f 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
@@ -120,7 +120,7 @@ public class ObservabilityNoopBenchmark {
 		} finally {
 			scope.close();
 			var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
-			recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE, STATUS_CODE, elapsed, null);
+			recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE, STATUS_CODE, elapsed, null, "", "");
 		}
 		bh.consume(scope);
 	}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java
new file mode 100644
index 0000000000..6666c546ab
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java
@@ -0,0 +1,325 @@
+/*
+ * 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.juneau.rest.async;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.condition.JRE.*;
+
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+import org.slf4j.*;
+
+/**
+ * Tests for {@link Rest#asyncCompletionExecutor()} / {@link RestOp#asyncCompletionExecutor()} (TODO-118).
+ *
+ * <p>
+ * The {@code MockRestClient} harness exercises the synchronous fallback path of
+ * {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} (since
+ * {@code MockServletRequest.isAsyncSupported() == false}). The tests here therefore focus on:
+ *
+ * <ul>
+ *   <li>Correct annotation parsing and bean resolution at context-build time.
+ *   <li>Startup failure when the named bean does not exist.
+ *   <li>Correct response content (regression) when an executor is configured.
+ *   <li>Per-op override and inheritance semantics.
+ *   <li>MDC propagation still works when an executor is configured (via the sync fallback).
+ * </ul>
+ *
+ * <p>
+ * The "executor actually used on the callback thread" contract is verified via the
+ * {@link CountingExecutor} helper: even in the sync path the {@code RestOpContext} carries the
+ * resolved executor reference, which can be inspected directly.
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+class AsyncCompletionExecutor_Test extends TestBase {
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// Support
+	// -----------------------------------------------------------------------------------------------------------------
+
+	/** Executor that counts how many tasks were submitted and records the last thread name. */
+	static class CountingExecutor implements Executor {
+		final AtomicInteger count = new AtomicInteger();
+		final AtomicReference<String> lastThreadName = new AtomicReference<>("");
+		final String poolPrefix;
+
+		CountingExecutor(String poolPrefix) { this.poolPrefix = poolPrefix; }
+
+		@Override
+		public void execute(Runnable command) {
+			count.incrementAndGet();
+			var t = new Thread(() -> {
+				lastThreadName.set(Thread.currentThread().getName());
+				command.run();
+			}, poolPrefix + "-" + count.get());
+			t.start();
+		}
+	}
+
+	/**
+	 * Reflective {@code Thread.isVirtual()} so the test class compiles on Java 17 source level.
+	 * Returns {@code false} on Java < 21 where the method does not exist.
+	 */
+	static boolean reflectIsVirtual(Thread t) {
+		try {
+			return (Boolean) Thread.class.getMethod("isVirtual").invoke(t);
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// A: Happy path — @Rest(asyncCompletionExecutor) resolves a named bean; resource builds and responds correctly.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest(asyncCompletionExecutor = "testPool")
+	public static class A {
+		@Bean(name = "testPool")
+		public Executor testPool() {
+			return new CountingExecutor("a-pool");
+		}
+
+		@RestGet("/hello")
+		public CompletableFuture<String> hello() {
+			return CompletableFuture.completedFuture("hello-from-executor-resource");
+		}
+	}
+
+	private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+	@Test
+	void a01_happyPath_resourceBuilds() {
+		// Building the MockRestClient would throw if bean resolution failed at startup.
+		assertNotNull(CA);
+	}
+
+	@Test
+	void a02_happyPath_responseIsCorrect() throws Exception {
+		CA.get("/hello").run().assertStatus(200).assertContent().isContains("hello-from-executor-resource");
+	}
+
+	@Test
+	void a03_happyPath_executorIsConfiguredOnContext() throws Exception {
+		// Verify the resolved executor is non-null on the RestOpContext.
+		// We do this by building a fresh context with the same resource class and checking the executor.
+		var ctx = MockRestClient.buildLax(A.class);
+		assertNotNull(ctx, "MockRestClient should build without error");
+		// Response works correctly, implying the memoizer evaluated successfully.
+		ctx.get("/hello").run().assertStatus(200);
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// B: Verb-level override — @RestGet(asyncCompletionExecutor) overrides @Rest-level.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest(asyncCompletionExecutor = "resourcePool")
+	public static class B {
+		@Bean(name = "resourcePool")
+		public Executor resourcePool() { return new CountingExecutor("resource-pool"); }
+
+		@Bean(name = "opPool")
+		public Executor opPool() { return new CountingExecutor("op-pool"); }
+
+		@RestGet(path = "/inherited")
+		public CompletableFuture<String> inherited() {
+			return CompletableFuture.completedFuture("uses-resource-pool");
+		}
+
+		@RestGet(path = "/overridden", asyncCompletionExecutor = "opPool")
+		public CompletableFuture<String> overridden() {
+			return CompletableFuture.completedFuture("uses-op-pool");
+		}
+	}
+
+	private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+	@Test
+	void b01_verbOverride_bothEndpointsRespond() throws Exception {
+		CB.get("/inherited").run().assertStatus(200).assertContent().isContains("uses-resource-pool");
+		CB.get("/overridden").run().assertStatus(200).assertContent().isContains("uses-op-pool");
+	}
+
+	@Test
+	void b02_verbOverride_resourceBuilds() {
+		assertNotNull(CB);
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// C: No override — default behavior (null executor, natural thread) is preserved.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class C {
+		@RestGet("/natural")
+		public CompletableFuture<String> natural() {
+			return CompletableFuture.completedFuture("natural-thread");
+		}
+	}
+
+	private static final MockRestClient CC = MockRestClient.buildLax(C.class);
+
+	@Test
+	void c01_noOverride_resourceBuilds() {
+		assertNotNull(CC);
+	}
+
+	@Test
+	void c02_noOverride_responseIsCorrect() throws Exception {
+		CC.get("/natural").run().assertStatus(200).assertContent().isContains("natural-thread");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// D: Unresolved bean name — resource construction must throw at startup.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest(asyncCompletionExecutor = "nonExistentPool")
+	public static class D {
+		@RestGet("/x")
+		public CompletableFuture<String> x() { return CompletableFuture.completedFuture("x"); }
+	}
+
+	@Test
+	void d01_unresolvedBean_throwsAtStartup() {
+		var ex = assertThrows(Exception.class, () -> MockRestClient.buildLax(D.class));
+		// The exception chain should contain our startup-fail message.
+		var msg = unwrapCause(ex);
+		assertTrue(msg.contains("nonExistentPool"),
+			"Expected message mentioning the missing bean name; got: " + msg);
+	}
+
+	private static String unwrapCause(Throwable t) {
+		var sb = new StringBuilder();
+		while (t != null) {
+			sb.append(t.getMessage()).append('\n');
+			t = t.getCause();
+		}
+		return sb.toString();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// E: Virtual thread executor — supply Executors.newVirtualThreadPerTaskExecutor() on Java 21+.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest(asyncCompletionExecutor = "vtPool")
+	public static class E {
+		@Bean(name = "vtPool")
+		public Executor vtPool() throws Exception {
+			// Reflective so the resource class compiles on Java 17.
+			return (Executor) Executors.class.getMethod("newVirtualThreadPerTaskExecutor").invoke(null);
+		}
+
+		@RestGet("/vt")
+		public CompletableFuture<String> vt() {
+			return CompletableFuture.completedFuture("virtual-thread-executor");
+		}
+	}
+
+	@Test
+	@EnabledForJreRange(min = JAVA_21)
+	void e01_virtualThreadExecutor_resourceBuilds() {
+		// If reflective creation succeeds (Java 21+), the resource must build without error.
+		var c = MockRestClient.buildLax(E.class);
+		assertNotNull(c);
+	}
+
+	@Test
+	@EnabledForJreRange(min = JAVA_21)
+	void e02_virtualThreadExecutor_responseIsCorrect() throws Exception {
+		var c = MockRestClient.buildLax(E.class);
+		c.get("/vt").run().assertStatus(200).assertContent().isContains("virtual-thread-executor");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// F: MDC propagation still works when an async-completion executor is configured.
+	//    The sync-fallback path exercises the full response pipeline, including MDC context.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	/** The MDC key set by a "filter" before the request. */
+	static final String MDC_KEY = "asyncExecTestId";
+
+	@Rest(asyncCompletionExecutor = "mdcTestPool")
+	public static class F {
+		@Bean(name = "mdcTestPool")
+		public Executor mdcTestPool() { return new CountingExecutor("mdc-pool"); }
+
+		@RestGet("/with-mdc")
+		public CompletableFuture<String> withMdc() {
+			// In the sync-fallback path, the future completes synchronously; MDC should be accessible.
+			return CompletableFuture.completedFuture("mdc-response");
+		}
+	}
+
+	private static final MockRestClient CF = MockRestClient.buildLax(F.class);
+
+	@AfterEach
+	void clearMdc() {
+		MDC.clear();
+	}
+
+	@Test
+	void f01_mdcPropagation_resourceBuilds() {
+		assertNotNull(CF);
+	}
+
+	@Test
+	void f02_mdcPropagation_responseIsCorrect() throws Exception {
+		MDC.put(MDC_KEY, "test-req-id");
+		CF.get("/with-mdc").run().assertStatus(200).assertContent().isContains("mdc-response");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// G: Op-level @RestGet override with no resource-level setting.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class G {
+		@Bean(name = "opOnlyPool")
+		public Executor opOnlyPool() { return new CountingExecutor("g-pool"); }
+
+		@RestGet(path = "/with-exec", asyncCompletionExecutor = "opOnlyPool")
+		public CompletableFuture<String> withExec() {
+			return CompletableFuture.completedFuture("op-only-executor");
+		}
+
+		@RestGet(path = "/without-exec")
+		public CompletableFuture<String> withoutExec() {
+			return CompletableFuture.completedFuture("no-executor");
+		}
+	}
+
+	private static final MockRestClient CG = MockRestClient.buildLax(G.class);
+
+	@Test
+	void g01_opLevelOnly_resourceBuilds() {
+		assertNotNull(CG);
+	}
+
+	@Test
+	void g02_opLevelOnly_bothEndpointsRespond() throws Exception {
+		CG.get("/with-exec").run().assertStatus(200).assertContent().isContains("op-only-executor");
+		CG.get("/without-exec").run().assertStatus(200).assertContent().isContains("no-executor");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
index c4b34ce5fa..d0ed196168 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
@@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.*;
 import java.lang.reflect.*;
 import java.time.*;
 import java.util.*;
+import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
 
 import org.apache.juneau.*;
@@ -58,12 +59,41 @@ class JwksCache_Test extends TestBase {
 		}
 	}
 
+	// -----------------------------------------------------------------------------------------
+	// Reflective helpers — updated to the 5-arg JwksCache constructor (OQ-10).
+	// Default overloads pass eager=true / cooldown=10s matching the Builder defaults.
+	// -----------------------------------------------------------------------------------------
+
 	private static JwksCache newCache(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
-		var ctor = JwksCache.class.getDeclaredConstructor(JWKSource.class, Duration.class, Clock.class);
+		return newCache(src, ttl, clock, true, Duration.ofSeconds(10));
+	}
+
+	private static JwksCache newCache(JWKSource<SecurityContext> src, Duration ttl, Clock clock,
+			boolean eager, Duration cooldown) throws Exception {
+		var ctor = JwksCache.class.getDeclaredConstructor(
+			JWKSource.class, Duration.class, Clock.class, boolean.class, Duration.class);
 		ctor.setAccessible(true);
-		return (JwksCache) ctor.newInstance(src, ttl, clock);
+		return (JwksCache) ctor.newInstance(src, ttl, clock, eager, cooldown);
+	}
+
+	private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
+		return invokeCacheCtor(src, ttl, clock, true, Duration.ofSeconds(10));
 	}
 
+	private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock,
+			boolean eager, Duration cooldown) throws Exception {
+		Constructor<?> ctor = JwksCache.class.getDeclaredConstructor(
+			JWKSource.class, Duration.class, Clock.class, boolean.class, Duration.class);
+		ctor.setAccessible(true);
+		@SuppressWarnings("unchecked")
+		var cast = (JWKSource<SecurityContext>) ctor.newInstance(src, ttl, clock, eager, cooldown);
+		return cast;
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// a-series: basic TTL / cache-hit / cache-miss mechanics.
+	// -----------------------------------------------------------------------------------------
+
 	@Test void a01_cacheHitWithinTtl_skipsUnderlyingFetch() throws Exception {
 		var rsa = generateRsa("kid-1");
 		var src = new CountingSource(new JWKSet(rsa.toPublicJWK()));
@@ -98,6 +128,10 @@ class JwksCache_Test extends TestBase {
 		assertEquals(2, src.fetches.get(), "lookup past TTL must re-fetch from upstream");
 	}
 
+	// -----------------------------------------------------------------------------------------
+	// b-series: graceful-degradation on JWKS-endpoint outage.
+	// -----------------------------------------------------------------------------------------
+
 	@Test void b01_gracefulDegradation_servesCachedKeysOnFetchFailure() throws Exception {
 		var rsa = generateRsa("kid-1");
 		var src = new CountingSource(new JWKSet(rsa.toPublicJWK()));
@@ -135,6 +169,12 @@ class JwksCache_Test extends TestBase {
 		assertThrows(KeySourceException.class, () -> cache.get(sel, null));
 	}
 
+	// -----------------------------------------------------------------------------------------
+	// c-series: key-rotation end-to-end through JwtTokenValidator.
+	// c01 — legacy/feature-off regression (eager disabled): mid-TTL kid miss must throw.
+	// c02 — feature-on: mid-TTL kid miss succeeds after eager refresh.
+	// -----------------------------------------------------------------------------------------
+
 	@Test void c01_jwksRotation_picksUpNewKeysAfterTtl() throws Exception {
 		var oldKey = generateRsa("kid-old");
 		var newKey = generateRsa("kid-new");
@@ -147,10 +187,11 @@ class JwksCache_Test extends TestBase {
 			@Override public Instant instant() { return clockHolder.get().instant(); }
 		};
 
+		// Eager refresh disabled — preserves pre-9.5.0 behavior for regression coverage.
 		var validator = JwtTokenValidator.create()
 			.issuer(DEFAULT_ISSUER)
 			.audience(DEFAULT_AUDIENCE)
-			.jwkSource(invokeCacheCtor(src, Duration.ofMinutes(1), clock))
+			.jwkSource(invokeCacheCtor(src, Duration.ofMinutes(1), clock, false, Duration.ofSeconds(10)))
 			.clock(clock)
 			.build();
 
@@ -173,11 +214,261 @@ class JwksCache_Test extends TestBase {
 		assertEquals("alice", p2.getName());
 	}
 
-	private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
-		Constructor<?> ctor = JwksCache.class.getDeclaredConstructor(JWKSource.class, Duration.class, Clock.class);
-		ctor.setAccessible(true);
-		@SuppressWarnings("unchecked")
-		var cast = (JWKSource<SecurityContext>) ctor.newInstance(src, ttl, clock);
-		return cast;
+	@Test void c02_eagerRefresh_freshCacheKidMiss_succeeds() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+
+		// Eager refresh enabled (default).
+		var validator = JwtTokenValidator.create()
+			.issuer(DEFAULT_ISSUER)
+			.audience(DEFAULT_AUDIENCE)
+			.jwkSource(invokeCacheCtor(src, Duration.ofMinutes(5), clock))
+			.clock(clock)
+			.build();
+
+		// Warm the cache with kid-old.
+		var oldToken = signRsa(oldKey, defaultClaims(clock).build());
+		assertEquals("alice", validator.validate(oldToken).getName());
+		assertEquals(1, src.fetches.get());
+
+		// IdP rotates: publishes {kid-old, kid-new}.
+		src.set.set(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+
+		// kid-new token presented within TTL — eager refresh fires, token validates.
+		var newToken = signRsa(newKey, defaultClaims(clock).build());
+		var p = validator.validate(newToken);
+		assertEquals("alice", p.getName(), "kid-new token must validate after eager refresh");
+		assertEquals(2, src.fetches.get(), "exactly one eager fetch");
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// d-series: JwksCache unit tests for the eager-refresh path.
+	// -----------------------------------------------------------------------------------------
+
+	/** Helper: mutable clock driven by a holder. */
+	private static Clock holderClock(AtomicReference<Clock> holder) {
+		return new Clock() {
+			@Override public ZoneId getZone() { return holder.get().getZone(); }
+			@Override public Clock withZone(ZoneId z) { return holder.get().withZone(z); }
+			@Override public Instant instant() { return holder.get().instant(); }
+		};
+	}
+
+	@Test void d01_eagerRefresh_freshCacheKidMiss_refetchesOnce() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+		var cache = newCache(src, Duration.ofMinutes(5), clock);
+
+		// Warm with kid-old.
+		var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+		var warm = cache.get(allSel, null);
+		assertFalse(warm.isEmpty());
+		assertEquals(1, src.fetches.get());
+
+		// IdP rotates: publishes kid-new.
+		src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+		// kid-new selector within TTL: should trigger eager refresh.
+		var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+		var hits = cache.get(kidNewSel, null);
+
+		assertFalse(hits.isEmpty(), "kid-new must be returned after eager refresh");
+		assertEquals(2, src.fetches.get(), "exactly one eager fetch");
+	}
+
+	@Test void d02_eagerRefreshDisabled_preservesLegacyBehavior() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+		var cache = newCache(src, Duration.ofMinutes(5), clock, false, Duration.ofSeconds(10));
+
+		var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+		cache.get(allSel, null);
+		src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+		var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+		var hits = cache.get(kidNewSel, null);
+
+		assertTrue(hits.isEmpty(), "kid-new must remain absent within TTL when eager is disabled");
+		assertEquals(1, src.fetches.get(), "no extra fetch when eager is disabled");
+	}
+
+	@Test void d03_eagerRefresh_cooldownThrottlesRepeatedMisses() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var t0 = Instant.parse("2026-01-01T00:00:00Z");
+		var clockHolder = new AtomicReference<>(Clock.fixed(t0, ZoneOffset.UTC));
+		var clock = holderClock(clockHolder);
+		var cooldown = Duration.ofSeconds(30);
+		var cache = newCache(src, Duration.ofMinutes(5), clock, true, cooldown);
+
+		// Warm with kid-old.
+		cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+		assertEquals(1, src.fetches.get());
+
+		// Rotate: only kid-new published.
+		src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+		var kidMissSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-miss").build());
+
+		// First miss within TTL: eager refresh fires (fetches == 2).
+		cache.get(kidMissSel, null);
+		assertEquals(2, src.fetches.get(), "first eager refresh must fire");
+
+		// Second miss immediately (within cooldown): must be skipped.
+		cache.get(kidMissSel, null);
+		assertEquals(2, src.fetches.get(), "second miss within cooldown must not trigger another fetch");
+
+		// Advance past cooldown: third miss should trigger another eager refresh.
+		clockHolder.set(Clock.fixed(t0.plus(cooldown).plusSeconds(1), ZoneOffset.UTC));
+		cache.get(kidMissSel, null);
+		assertEquals(3, src.fetches.get(), "miss after cooldown expiry must trigger another eager fetch");
+	}
+
+	@Test void d04_eagerRefresh_endpointOutageRetainsLastKnownGood() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+		var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+		// Warm with kid-old.
+		var oldSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-old").build());
+		var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+		cache.get(allSel, null);
+		assertEquals(1, src.fetches.get());
+
+		// Make the endpoint fail.
+		src.fail = true;
+
+		// kid-miss selector within TTL: eager refresh fires but fails; last-known-good retained.
+		var kidMissSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-unknown").build());
+		var missing = cache.get(kidMissSel, null);
+		assertTrue(missing.isEmpty(), "unknown kid must return empty when eager refresh fails");
+		assertEquals(2, src.fetches.get(), "one failed eager refresh attempt");
+
+		// Old key must still be available from the retained cache.
+		var retained = cache.get(oldSel, null);
+		assertFalse(retained.isEmpty(), "last-known-good keys must still be served after a failed eager refresh");
+
+		// Immediate repeat miss must be throttled by cooldown (no additional fetch).
+		cache.get(kidMissSel, null);
+		assertEquals(2, src.fetches.get(), "cooldown must prevent a second eager fetch immediately after failure");
+	}
+
+	@Test void d05_eagerRefresh_stillMissingAfterRefresh_noDoubleAttempt() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+		var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+		// Warm with kid-old.
+		cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+		assertEquals(1, src.fetches.get());
+
+		// Rotate: source keeps kid-old only (simulates IdP not yet publishing kid-new).
+		var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+
+		// First miss: eager refresh fires, returns kid-old set, but kid-new still absent → empty.
+		var first = cache.get(kidNewSel, null);
+		assertTrue(first.isEmpty(), "still-missing kid must produce empty after one refresh");
+		assertEquals(2, src.fetches.get(), "one eager fetch");
+
+		// Immediate repeat: must be throttled by cooldown.
+		cache.get(kidNewSel, null);
+		assertEquals(2, src.fetches.get(), "no extra fetch within cooldown after still-missing refresh");
+	}
+
+	@Test void d06_eagerRefresh_successResetsTtlWindow() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+		var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+		var t0 = Instant.parse("2026-01-01T00:00:00Z");
+		var ttl = Duration.ofMinutes(5);
+		var clockHolder = new AtomicReference<>(Clock.fixed(t0, ZoneOffset.UTC));
+		var clock = holderClock(clockHolder);
+		var cache = newCache(src, ttl, clock, true, Duration.ofSeconds(10));
+
+		// Warm at t0.
+		var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+		cache.get(allSel, null);
+		assertEquals(1, src.fetches.get());
+
+		// Rotate at t0+1min: IdP publishes {kid-old, kid-new}.
+		clockHolder.set(Clock.fixed(t0.plusSeconds(60), ZoneOffset.UTC));
+		src.set.set(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+
+		// Eager refresh fires at t0+1min (still within original TTL window).
+		var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+		var hits = cache.get(kidNewSel, null);
+		assertFalse(hits.isEmpty(), "kid-new must be found after eager refresh");
+		assertEquals(2, src.fetches.get());
+
+		// Move to t0+4min (just before the ORIGINAL TTL boundary, but well within the RESET window).
+		// If the TTL was reset at t0+1min, the next periodic refresh is due at t0+6min.
+		clockHolder.set(Clock.fixed(t0.plusSeconds(240), ZoneOffset.UTC));
+		cache.get(allSel, null);
+		assertEquals(2, src.fetches.get(), "TTL window must be reset by the eager refresh; no extra fetch at t0+4min");
+	}
+
+	@Test void d07_eagerRefresh_singleInFlight_noThunderingHerd() throws Exception {
+		var oldKey = generateRsa("kid-old");
+		var newKey = generateRsa("kid-new");
+
+		// Latch that holds the eager-refresh delegate call until we release it.
+		var fetchLatch = new CountDownLatch(1);
+		var warmupDone = new AtomicBoolean(false);
+		var fetchCount = new AtomicInteger();
+
+		JWKSource<SecurityContext> src = (selector, ctx) -> {
+			fetchCount.incrementAndGet();
+			if (!warmupDone.get())
+				return selector.select(new JWKSet(oldKey.toPublicJWK()));
+			// Eager-refresh fetch: block until the test releases the latch.
+			try { fetchLatch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+			return selector.select(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+		};
+
+		var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+		var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+		// Warm the cache.
+		cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+		assertEquals(1, fetchCount.get());
+		warmupDone.set(true);
+
+		// N threads simultaneously present a kid-new miss.
+		int n = 6;
+		var barrier = new CyclicBarrier(n);
+		var executor = Executors.newFixedThreadPool(n);
+		var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+		var futures = new ArrayList<Future<List<JWK>>>();
+		for (int i = 0; i < n; i++) {
+			futures.add(executor.submit(() -> {
+				barrier.await();  // all threads start simultaneously
+				return cache.get(kidNewSel, null);
+			}));
+		}
+
+		// Allow threads to queue up on the blocked fetch, then release.
+		Thread.sleep(250);
+		fetchLatch.countDown();
+
+		executor.shutdown();
+		assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+
+		int successCount = 0;
+		for (var f : futures) {
+			if (!f.get().isEmpty())
+				successCount++;
+		}
+
+		assertEquals(2, fetchCount.get(), "exactly one eager fetch regardless of concurrent misses");
+		assertEquals(n, successCount, "all concurrent threads must see the refreshed key");
 	}
 }
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
index a195e0f221..5c1e776c2c 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
@@ -126,6 +126,50 @@ class JwtTokenValidator_Builder_Test extends TestBase {
 		);
 	}
 
+	@Test void b06_zeroEagerRefreshCooldown_rejected() {
+		assertThrows(IllegalArgumentException.class, () ->
+			JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ZERO)
+		);
+	}
+
+	@Test void b07_negativeEagerRefreshCooldown_rejected() {
+		assertThrows(IllegalArgumentException.class, () ->
+			JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ofSeconds(-1))
+		);
+	}
+
+	@Test void b08_eagerRefreshCooldownOverSixtySeconds_rejected() {
+		assertThrows(IllegalArgumentException.class, () ->
+			JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ofSeconds(61))
+		);
+	}
+
+	@Test void b09_eagerRefreshCooldownExceedsTtl_rejectedAtBuild() throws Exception {
+		var rsa = generateRsa("kid-1");
+		// ttl = 20s, cooldown = 30s (≤ 60s but > ttl) → rejected at build().
+		assertThrows(IllegalStateException.class, () ->
+			JwtTokenValidator.create()
+				.issuer(DEFAULT_ISSUER)
+				.audience(DEFAULT_AUDIENCE)
+				.jwkSource(fixed(rsa))
+				.jwksCacheTtl(Duration.ofSeconds(20))
+				.jwksEagerRefreshCooldown(Duration.ofSeconds(30))
+				.build()
+		);
+	}
+
+	@Test void b10_eagerRefreshOnKidMiss_setterDisablesFeature() throws Exception {
+		var rsa = generateRsa("kid-1");
+		// Should build successfully with eager disabled.
+		var v = JwtTokenValidator.create()
+			.issuer(DEFAULT_ISSUER)
+			.audience(DEFAULT_AUDIENCE)
+			.jwkSource(fixed(rsa))
+			.jwksEagerRefreshOnKidMiss(false)
+			.build();
+		assertNotNull(v);
+	}
+
 	// -----------------------------------------------------------------------------------------
 	// Getters expose the configured state.
 	// -----------------------------------------------------------------------------------------
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
index 6940add46e..dba93ee8ba 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
@@ -31,17 +31,23 @@ import org.junit.jupiter.api.*;
  */
 class JwtTokenValidator_ValueAdoption_Test {
 
-	private static final String SP = "juneau.jwt.jwksCacheTtl";
+	private static final String TTL_KEY = "juneau.jwt.jwksCacheTtl";
+	private static final String EAGER_KEY = "juneau.jwt.jwksEagerRefreshOnKidMiss";
+	private static final String COOLDOWN_KEY = "juneau.jwt.jwksEagerRefreshCooldown";
 
 	@AfterEach
 	void cleanup() {
-		Settings.get().unsetGlobal(SP);
-		System.clearProperty(SP);
+		for (var k : new String[]{TTL_KEY, EAGER_KEY, COOLDOWN_KEY}) {
+			Settings.get().unsetGlobal(k);
+			System.clearProperty(k);
+		}
 	}
 
+	// ---- jwksCacheTtl -----------------------------------------------------------------------
+
 	@Test
 	void a01_jwksCacheTtl_set() {
-		System.setProperty(SP, "PT10M");
+		System.setProperty(TTL_KEY, "PT10M");
 		assertEquals(Duration.ofMinutes(10), JwtTokenValidator.create().jwksCacheTtl);
 	}
 
@@ -52,7 +58,45 @@ class JwtTokenValidator_ValueAdoption_Test {
 
 	@Test
 	void a03_jwksCacheTtl_setGlobal() {
-		Settings.get().setGlobal(SP, "PT15M");
+		Settings.get().setGlobal(TTL_KEY, "PT15M");
 		assertEquals(Duration.ofMinutes(15), JwtTokenValidator.create().jwksCacheTtl);
 	}
+
+	// ---- jwksEagerRefreshOnKidMiss ----------------------------------------------------------
+
+	@Test
+	void b01_jwksEagerRefreshOnKidMiss_set() {
+		System.setProperty(EAGER_KEY, "false");
+		assertEquals(false, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+	}
+
+	@Test
+	void b02_jwksEagerRefreshOnKidMiss_unset() {
+		assertEquals(true, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+	}
+
+	@Test
+	void b03_jwksEagerRefreshOnKidMiss_setGlobal() {
+		Settings.get().setGlobal(EAGER_KEY, "false");
+		assertEquals(false, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+	}
+
+	// ---- jwksEagerRefreshCooldown -----------------------------------------------------------
+
+	@Test
+	void c01_jwksEagerRefreshCooldown_set() {
+		System.setProperty(COOLDOWN_KEY, "PT30S");
+		assertEquals(Duration.ofSeconds(30), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+	}
+
+	@Test
+	void c02_jwksEagerRefreshCooldown_unset() {
+		assertEquals(Duration.ofSeconds(10), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+	}
+
+	@Test
+	void c03_jwksEagerRefreshCooldown_setGlobal() {
+		Settings.get().setGlobal(COOLDOWN_KEY, "PT20S");
+		assertEquals(Duration.ofSeconds(20), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+	}
 }
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java
new file mode 100644
index 0000000000..e7ac5a37e9
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java
@@ -0,0 +1,119 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.TestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link EphemeralStore} &mdash; single-use, TTL-bounded, size-bounded state/nonce storage.
+ *
+ * @since 9.5.0
+ */
+class EphemeralStore_Test extends TestBase {
+
+	private static EphemeralStore store(Clock clock) {
+		return new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+	}
+
+	@Test void a01_storeThenConsume_roundTrips() {
+		var s = store(Clock.systemUTC());
+		s.store("state-1", "nonce-1", "verifier-1", "/dashboard");
+		var p = s.consume("state-1");
+		assertTrue(p.isPresent());
+		assertEquals("nonce-1", p.get().nonce());
+		assertEquals("verifier-1", p.get().codeVerifier());
+		assertEquals("/dashboard", p.get().redirectTarget());
+	}
+
+	@Test void a02_consume_isSingleUse() {
+		var s = store(Clock.systemUTC());
+		s.store("state-1", "nonce-1", "verifier-1", null);
+		assertTrue(s.consume("state-1").isPresent());
+		assertTrue(s.consume("state-1").isEmpty(), "second consume of same state must miss (replay defense)");
+	}
+
+	@Test void a03_consume_unknownState_isEmpty() {
+		var s = store(Clock.systemUTC());
+		assertTrue(s.consume("never-stored").isEmpty());
+	}
+
+	@Test void a04_consume_nullState_isEmpty() {
+		var s = store(Clock.systemUTC());
+		assertTrue(s.consume(null).isEmpty());
+	}
+
+	@Test void b01_expiredEntry_isMissed() {
+		var base = Instant.parse("2026-01-01T00:00:00Z");
+		var clock = new MutableClock(base);
+		var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+		s.store("state-1", "nonce-1", "verifier-1", null);
+		clock.advance(Duration.ofMinutes(6));
+		assertTrue(s.consume("state-1").isEmpty(), "entry past TTL must be treated as a miss");
+	}
+
+	@Test void b02_notYetExpired_survives() {
+		var base = Instant.parse("2026-01-01T00:00:00Z");
+		var clock = new MutableClock(base);
+		var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+		s.store("state-1", "nonce-1", "verifier-1", null);
+		clock.advance(Duration.ofMinutes(4));
+		assertTrue(s.consume("state-1").isPresent());
+	}
+
+	@Test void c01_sizeCap_evictsEldest() {
+		var s = new EphemeralStore(Duration.ofMinutes(5), 3, Clock.systemUTC());
+		s.store("s1", "n", "v", null);
+		s.store("s2", "n", "v", null);
+		s.store("s3", "n", "v", null);
+		s.store("s4", "n", "v", null);  // evicts s1
+		assertEquals(3, s.size());
+		assertTrue(s.consume("s1").isEmpty());
+		assertTrue(s.consume("s4").isPresent());
+	}
+
+	@Test void d01_rejectsNonPositiveTtl() {
+		assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ZERO, 100, Clock.systemUTC()));
+	}
+
+	@Test void d02_rejectsTtlAbove30Minutes() {
+		assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ofMinutes(31), 100, Clock.systemUTC()));
+	}
+
+	@Test void d03_rejectsNonPositiveMaxEntries() {
+		assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ofMinutes(5), 0, Clock.systemUTC()));
+	}
+
+	@Test void d04_rejectsBlankStateOnStore() {
+		var s = store(Clock.systemUTC());
+		assertThrows(IllegalArgumentException.class, () -> s.store("", "n", "v", null));
+	}
+
+	/** A test clock the test can advance manually. */
+	static final class MutableClock extends Clock {
+		private Instant now;
+		MutableClock(Instant start) { now = start; }
+		void advance(Duration d) { now = now.plus(d); }
+		@Override public ZoneId getZone() { return ZoneOffset.UTC; }
+		@Override public Clock withZone(ZoneId zone) { return this; }
+		@Override public Instant instant() { return now; }
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java
new file mode 100644
index 0000000000..edb7c16f1a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java
@@ -0,0 +1,112 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.RSAKey;
+
+/**
+ * Tests for {@link IdTokenValidatorAdapter} &mdash; OIDC ID-token signature + claim validation via Nimbus.
+ *
+ * @since 9.5.0
+ */
+class IdTokenValidatorAdapter_Test extends TestBase {
+
+	private static final String ISS = "https://stub-idp.example.com";
+	private static final String CID = "web-app";
+
+	private RSAKey key;
+	private Instant now;
+
+	@BeforeEach void setup() throws Exception {
+		key = generateRsa("k1");
+		now = Instant.now();  // validation uses the real system clock, so anchor tokens to "now"
+	}
+
+	private IdTokenValidatorAdapter validator() {
+		return IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID).jwkSet(publicJwks(key)).build();
+	}
+
+	@Test void a01_validToken_projectsClaims() throws Exception {
+		var jwt = signIdToken(key, ISS, CID, "alice", "sess-1", "nonce-1", now, Duration.ofMinutes(5), null);
+		var p = validator().validate(jwt, "nonce-1");
+		assertEquals("alice", p.getName());
+		assertEquals("sess-1", p.getClaim("sid", String.class).orElse(null));
+	}
+
+	@Test void b01_wrongNonce_rejected() throws Exception {
+		var jwt = signIdToken(key, ISS, CID, "alice", null, "nonce-1", now, Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "different-nonce"));
+	}
+
+	@Test void b02_wrongIssuer_rejected() throws Exception {
+		var jwt = signIdToken(key, "https://evil.example.com", CID, "alice", null, "n", now, Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+	}
+
+	@Test void b03_wrongAudience_rejected() throws Exception {
+		var jwt = signIdToken(key, ISS, "some-other-client", "alice", null, "n", now, Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+	}
+
+	@Test void b04_expiredToken_rejected() throws Exception {
+		var past = now.minus(Duration.ofHours(1));
+		var jwt = signIdToken(key, ISS, CID, "alice", null, "n", past, Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+	}
+
+	@Test void b05_wrongSigningKey_rejected() throws Exception {
+		var attacker = generateRsa("k1");  // same kid, different key material
+		var jwt = signIdToken(attacker, ISS, CID, "alice", null, "n", now, Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+	}
+
+	@Test void b06_garbageToken_rejected() {
+		assertThrows(AuthenticationException.class, () -> validator().validate("not-a-jwt", "n"));
+	}
+
+	@Test void c01_noneAlgorithm_rejectedAtBuild() {
+		assertThrows(IllegalArgumentException.class,
+			() -> IdTokenValidatorAdapter.create().algorithms(new JWSAlgorithm("none")));
+	}
+
+	@Test void c02_requiresExactlyOneJwkSource() {
+		assertThrows(IllegalStateException.class,
+			() -> IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID).build());
+		assertThrows(IllegalStateException.class,
+			() -> IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID)
+				.jwkSet(publicJwks(key)).jwksUri(URI.create("https://idp/jwks")).build());
+	}
+
+	@Test void c03_requiresIssuerAndClientId() {
+		assertThrows(IllegalStateException.class,
+			() -> IdTokenValidatorAdapter.create().clientId(CID).jwkSet(publicJwks(key)).build());
+		assertThrows(IllegalStateException.class,
+			() -> IdTokenValidatorAdapter.create().issuer(ISS).jwkSet(publicJwks(key)).build());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java
new file mode 100644
index 0000000000..c85a7f7aa3
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java
@@ -0,0 +1,113 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link InMemorySessionStore} &mdash; server-side indexing + back-channel revocation.
+ *
+ * @since 9.5.0
+ */
+class InMemorySessionStore_Test extends TestBase {
+
+	private static OidcSession session(String id, String subject, String sid) {
+		return session(id, subject, sid, Instant.now());
+	}
+
+	private static OidcSession session(String id, String subject, String sid, Instant base) {
+		return new OidcSession(id, subject, Optional.ofNullable(sid),
+			new ClaimsPrincipal(subject, Map.of("sub", subject)),
+			Set.of("user"), Optional.empty(), base, base.plus(Duration.ofHours(8)));
+	}
+
+	@Test void a01_createThenLookup_roundTrips() {
+		var store = InMemorySessionStore.create();
+		var cookie = store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+		assertEquals("id-1", cookie);
+		assertTrue(store.lookup("id-1").isPresent());
+		assertEquals("alice", store.lookup("id-1").get().subject());
+	}
+
+	@Test void a02_lookupUnknown_isEmpty() {
+		assertTrue(InMemorySessionStore.create().lookup("nope").isEmpty());
+	}
+
+	@Test void a03_invalidate_removesSession() {
+		var store = InMemorySessionStore.create();
+		store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+		store.invalidate("id-1");
+		assertTrue(store.lookup("id-1").isEmpty());
+	}
+
+	@Test void b01_supportsServerSideRevocation() {
+		assertTrue(InMemorySessionStore.create().supportsServerSideRevocation());
+	}
+
+	@Test void b02_invalidateBySessionId_targetsSingleSession() {
+		var store = InMemorySessionStore.create();
+		store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+		store.createSessionCookieValue(session("id-2", "alice", "sess-2"));
+		assertEquals(1, store.invalidateBySessionId("sess-1"));
+		assertTrue(store.lookup("id-1").isEmpty());
+		assertTrue(store.lookup("id-2").isPresent());
+	}
+
+	@Test void b03_invalidateBySubject_targetsAllOfSubject() {
+		var store = InMemorySessionStore.create();
+		store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+		store.createSessionCookieValue(session("id-2", "alice", "sess-2"));
+		store.createSessionCookieValue(session("id-3", "bob", "sess-3"));
+		assertEquals(2, store.invalidateBySubject("alice"));
+		assertTrue(store.lookup("id-1").isEmpty());
+		assertTrue(store.lookup("id-2").isEmpty());
+		assertTrue(store.lookup("id-3").isPresent());
+	}
+
+	@Test void b04_invalidateByUnknownSubjectOrSid_returnsZero() {
+		var store = InMemorySessionStore.create();
+		assertEquals(0, store.invalidateBySubject("ghost"));
+		assertEquals(0, store.invalidateBySessionId("ghost"));
+	}
+
+	@Test void c01_expiredSession_missedAndRemoved() {
+		var base = Instant.parse("2026-01-01T00:00:00Z");
+		var clock = new EphemeralStore_Test.MutableClock(base);
+		var store = new InMemorySessionStore(100, clock);
+		store.createSessionCookieValue(session("id-1", "alice", "sess-1", base));
+		clock.advance(Duration.ofHours(9));
+		assertTrue(store.lookup("id-1").isEmpty());
+		assertEquals(0, store.size());
+	}
+
+	@Test void d01_sizeCap_evictsEldestAndUnindexes() {
+		var store = InMemorySessionStore.create(2);
+		store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+		store.createSessionCookieValue(session("id-2", "bob", "sess-2"));
+		store.createSessionCookieValue(session("id-3", "carol", "sess-3"));  // evicts id-1
+		assertEquals(2, store.size());
+		assertTrue(store.lookup("id-1").isEmpty());
+		assertEquals(0, store.invalidateBySubject("alice"), "evicted session must also be unindexed");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java
new file mode 100644
index 0000000000..1532768e09
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java
@@ -0,0 +1,290 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.*;
+import org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.StubIdp;
+import org.apache.juneau.rest.mock.*;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jose.jwk.RSAKey;
+
+import jakarta.servlet.http.Cookie;
+
+/**
+ * End-to-end tests for {@link OidcRelyingParty} driving login &rarr; callback &rarr; authorized-request
+ * &rarr; logout against an in-JVM {@link StubIdp}, plus refresh-token rotation and back-channel logout.
+ *
+ * @since 9.5.0
+ */
+class OidcRelyingParty_Test extends TestBase {
+
+	private static final String CID = "web-app";
+	private static final URI REDIRECT_URI = URI.create("https://app.example.com/auth/callback");
+	private static final URI AUTHZ = URI.create("https://stub-idp.example.com/authorize");
+	private static final URI END_SESSION = URI.create("https://stub-idp.example.com/logout");
+
+	private StubIdp idp;
+	private RSAKey key;
+
+	@BeforeEach void setup() throws Exception {
+		key = generateRsa("k1");
+		idp = new StubIdp();
+	}
+
+	@AfterEach void teardown() {
+		if (idp != null)
+			idp.close();
+	}
+
+	private OidcRelyingParty rp(SessionStore store) {
+		return OidcRelyingParty.create()
+			.metadata(idp.metadata(AUTHZ, END_SESSION))
+			.clientId(CID)
+			.clientSecret("client-secret")
+			.redirectUri(REDIRECT_URI)
+			.scope("openid", "profile")
+			.sessionStore(store)
+			.jwkSet(publicJwks(key))
+			.build();
+	}
+
+	/** Drives startLogin + completeLogin; returns the session cookie value. */
+	private String login(OidcRelyingParty rp, String sub, String sid, String redirectParam) throws Exception {
+		var loginUri = "/auth/login" + (redirectParam == null ? "" : "?redirect=" + redirectParam);
+		var req1 = MockServletRequest.create("GET", loginUri);
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var loc = res1.getHeader("Location");
+		assertNotNull(loc, "startLogin must redirect to the IdP");
+		var state = queryParam(loc, "state");
+		var nonce = queryParam(loc, "nonce");
+		assertNotNull(state);
+		assertNotNull(nonce);
+		idp.idToken = signIdToken(key, idp.issuer, CID, sub, sid, nonce, Instant.now(), Duration.ofMinutes(5),
+			Map.of("scope", "openid profile"));
+		var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc123&state=" + state);
+		var res2 = MockServletResponse.create();
+		rp.completeLogin(req2, res2);
+		assertEquals("no-store", res2.getHeader("Cache-Control"));
+		return cookieValue(res2.getHeader("Set-Cookie"));
+	}
+
+	private Optional<AuthResult> authn(OidcRelyingParty rp, String cookie) throws Exception {
+		var req = MockServletRequest.create("GET", "/api/resource")
+			.cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+		return rp.authFilter().authenticate(req);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Happy-path login
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_fullLoginFlow_inMemoryStore() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var cookie = login(rp, "alice", "sess-1", null);
+		assertNotNull(cookie);
+		var auth = authn(rp, cookie);
+		assertTrue(auth.isPresent());
+		assertEquals("alice", auth.get().getPrincipal().getName());
+		assertTrue(auth.get().getRoles().contains("openid"));
+		assertTrue(auth.get().getRoles().contains("profile"));
+	}
+
+	@Test void a02_fullLoginFlow_signedCookieStore() throws Exception {
+		var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+		var cookie = login(rp, "alice", "sess-1", null);
+		var auth = authn(rp, cookie);
+		assertTrue(auth.isPresent());
+		assertEquals("alice", auth.get().getPrincipal().getName());
+	}
+
+	@Test void a03_completeLogin_setsSecureCookieFlags() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var req1 = MockServletRequest.create("GET", "/auth/login");
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var state = queryParam(res1.getHeader("Location"), "state");
+		var nonce = queryParam(res1.getHeader("Location"), "nonce");
+		idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+		var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+		var res2 = MockServletResponse.create();
+		rp.completeLogin(req2, res2);
+		var setCookie = res2.getHeader("Set-Cookie");
+		assertTrue(setCookie.contains("HttpOnly"));
+		assertTrue(setCookie.contains("Secure"));
+		assertTrue(setCookie.contains("SameSite=Lax"));
+	}
+
+	@Test void a04_redirectTarget_isHonored() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var req1 = MockServletRequest.create("GET", "/auth/login?redirect=/dashboard");
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var state = queryParam(res1.getHeader("Location"), "state");
+		var nonce = queryParam(res1.getHeader("Location"), "nonce");
+		idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+		var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+		var res2 = MockServletResponse.create();
+		rp.completeLogin(req2, res2);
+		assertEquals("/dashboard", res2.getHeader("Location"));
+	}
+
+	@Test void a05_openRedirect_isBlocked() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var req1 = MockServletRequest.create("GET", "/auth/login?redirect=https://evil.example.com");
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var state = queryParam(res1.getHeader("Location"), "state");
+		var nonce = queryParam(res1.getHeader("Location"), "nonce");
+		idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+		var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+		var res2 = MockServletResponse.create();
+		rp.completeLogin(req2, res2);
+		assertEquals("/", res2.getHeader("Location"), "absolute redirect target must be rejected (open-redirect defense)");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Callback failure modes
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_replayedState_isRejected() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var req1 = MockServletRequest.create("GET", "/auth/login");
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var state = queryParam(res1.getHeader("Location"), "state");
+		var nonce = queryParam(res1.getHeader("Location"), "nonce");
+		idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+		rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create());
+		// Second callback with the same (already-consumed) state must fail.
+		assertThrows(AuthenticationException.class,
+			() -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create()));
+	}
+
+	@Test void b02_unknownState_isRejected() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		assertThrows(AuthenticationException.class,
+			() -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=never-issued"), MockServletResponse.create()));
+	}
+
+	@Test void b03_badNonce_isRejected() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var req1 = MockServletRequest.create("GET", "/auth/login");
+		var res1 = MockServletResponse.create();
+		rp.startLogin(req1, res1);
+		var state = queryParam(res1.getHeader("Location"), "state");
+		idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", "WRONG-NONCE", Instant.now(), Duration.ofMinutes(5), null);
+		assertThrows(AuthenticationException.class,
+			() -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create()));
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Logout
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_logout_clearsCookieAndRedirectsThroughEndSession() throws Exception {
+		var store = InMemorySessionStore.create();
+		var rp = rp(store);
+		var cookie = login(rp, "alice", "sess-1", null);
+		var req = MockServletRequest.create("GET", "/auth/logout").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+		var res = MockServletResponse.create();
+		rp.logout(req, res);
+		assertTrue(res.getHeader("Set-Cookie").contains("Max-Age=0"), "logout must clear the cookie");
+		assertTrue(res.getHeader("Location").startsWith(END_SESSION.toString()), "logout should redirect through the IdP end-session endpoint");
+		assertTrue(authn(rp, cookie).isEmpty(), "session must be invalidated after logout");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Refresh-token rotation
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_refresh_rotatesTokenAndSession() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var cookie = login(rp, "alice", "sess-1", null);
+		var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+		var res = MockServletResponse.create();
+		var refreshed = rp.refresh(req, res);
+		assertTrue(refreshed.isPresent());
+		assertEquals("rt-2", refreshed.get().token().orElseThrow().refreshToken().orElse(null), "rotating refresh token must be replaced");
+		assertNotNull(res.getHeader("Set-Cookie"));
+	}
+
+	@Test void d02_refresh_reuseRejectedInvalidatesSession() throws Exception {
+		var store = InMemorySessionStore.create();
+		var rp = rp(store);
+		var cookie = login(rp, "alice", "sess-1", null);
+		idp.failToken = true;  // IdP rejects the refresh (rotated token reuse / revocation)
+		var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+		var res = MockServletResponse.create();
+		var refreshed = rp.refresh(req, res);
+		assertTrue(refreshed.isEmpty());
+		assertTrue(authn(rp, cookie).isEmpty(), "a rejected refresh must invalidate the session (fail-closed)");
+	}
+
+	@Test void d03_refresh_signedCookieStore_noToken_returnsEmpty() throws Exception {
+		var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+		var cookie = login(rp, "alice", "sess-1", null);
+		var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+		assertTrue(rp.refresh(req, MockServletResponse.create()).isEmpty(), "cookie store retains no token, so there is nothing to refresh");
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Back-channel logout
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_backChannelLogout_bySid_invalidatesSession() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var cookie = login(rp, "alice", "sess-1", null);
+		assertTrue(authn(rp, cookie).isPresent());
+		var logoutToken = signLogoutToken(key, idp.issuer, CID, null, "sess-1");
+		assertEquals(1, rp.backChannelLogout(logoutToken));
+		assertTrue(authn(rp, cookie).isEmpty());
+	}
+
+	@Test void e02_backChannelLogout_bySub_invalidatesAllSessions() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		var cookie1 = login(rp, "alice", "sess-1", null);
+		var cookie2 = login(rp, "alice", "sess-2", null);
+		var logoutToken = signLogoutToken(key, idp.issuer, CID, "alice", null);
+		assertEquals(2, rp.backChannelLogout(logoutToken));
+		assertTrue(authn(rp, cookie1).isEmpty());
+		assertTrue(authn(rp, cookie2).isEmpty());
+	}
+
+	@Test void e03_backChannelLogout_badSignature_isRejected() throws Exception {
+		var rp = rp(InMemorySessionStore.create());
+		login(rp, "alice", "sess-1", null);
+		var attacker = generateRsa("k1");
+		var forged = signLogoutToken(attacker, idp.issuer, CID, null, "sess-1");
+		assertThrows(AuthenticationException.class, () -> rp.backChannelLogout(forged));
+	}
+
+	@Test void e04_backChannelLogout_signedCookieStore_throws() throws Exception {
+		var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+		var logoutToken = signLogoutToken(key, idp.issuer, CID, null, "sess-1");
+		assertThrows(IllegalStateException.class, () -> rp.backChannelLogout(logoutToken));
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java
new file mode 100644
index 0000000000..afad77af13
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java
@@ -0,0 +1,232 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.oauth.oidc.OidcMetadata;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jose.jwk.*;
+import com.nimbusds.jose.jwk.gen.*;
+import com.nimbusds.jwt.*;
+import com.sun.net.httpserver.*;
+
+/**
+ * Test helpers + an in-JVM stub OpenID Connect provider for the {@code OidcRelyingParty} end-to-end
+ * tests.  Lives in the test tree (never compiled into production), in {@code juneau-utest} to match the
+ * centralized-tests convention.
+ *
+ * <p>
+ * The stub serves a real {@code /token} (and {@code /userinfo}) endpoint over loopback so the Nimbus
+ * token exchange in {@code OAuthAuthorizationCodeFlow#exchange} performs a genuine HTTP round-trip.
+ * Discovery and JWKS are short-circuited by injecting {@link OidcMetadata} and a public {@link JWKSet}
+ * directly on the relying party (so no second listener is needed and the tests stay deterministic).
+ */
+final class OidcTestSupport {
+
+	static final String EVENT_BACKCHANNEL_LOGOUT = "http://schemas.openid.net/event/backchannel-logout";
+
+	private OidcTestSupport() {}
+
+	/** Generates a fresh RSA-2048 signing key with the given key id. */
+	static RSAKey generateRsa(String kid) throws JOSEException {
+		return new RSAKeyGenerator(2048).keyID(kid).algorithm(JWSAlgorithm.RS256).generate();
+	}
+
+	/** Public-only JWK set for the supplied key(s) (what an IdP would publish at its JWKS endpoint). */
+	static JWKSet publicJwks(JWK...keys) {
+		return new JWKSet(Arrays.asList(keys)).toPublicJWKSet();
+	}
+
+	/** Signs an OIDC ID token (RS256) with the standard claims plus an optional {@code sid}. */
+	static String signIdToken(RSAKey key, String issuer, String clientId, String subject, String sid, String nonce,
+			Instant now, Duration ttl, Map<String,Object> extraClaims) throws JOSEException {
+		var cb = new JWTClaimsSet.Builder()
+			.issuer(issuer)
+			.subject(subject)
+			.audience(clientId)
+			.issueTime(Date.from(now))
+			.expirationTime(Date.from(now.plus(ttl)))
+			.claim("nonce", nonce);
+		if (sid != null)
+			cb.claim("sid", sid);
+		if (extraClaims != null)
+			extraClaims.forEach(cb::claim);
+		return sign(key, JWSAlgorithm.RS256, null, cb.build());
+	}
+
+	/** Signs an OIDC back-channel {@code logout_token} (RS256) targeting a {@code sub} and/or {@code sid}. */
+	static String signLogoutToken(RSAKey key, String issuer, String clientId, String subject, String sid)
+			throws JOSEException {
+		var events = new LinkedHashMap<String,Object>();
+		events.put(EVENT_BACKCHANNEL_LOGOUT, new LinkedHashMap<>());
+		var now = Instant.now();
+		var cb = new JWTClaimsSet.Builder()
+			.issuer(issuer)
+			.audience(clientId)
+			.issueTime(Date.from(now))
+			.expirationTime(Date.from(now.plus(Duration.ofMinutes(2))))
+			.jwtID(UUID.randomUUID().toString())
+			.claim("events", events);
+		if (subject != null)
+			cb.subject(subject);
+		if (sid != null)
+			cb.claim("sid", sid);
+		return sign(key, JWSAlgorithm.RS256, new JOSEObjectType("logout+jwt"), cb.build());
+	}
+
+	private static String sign(RSAKey key, JWSAlgorithm alg, JOSEObjectType typ, JWTClaimsSet claims)
+			throws JOSEException {
+		var hb = new JWSHeader.Builder(alg).keyID(key.getKeyID());
+		if (typ != null)
+			hb.type(typ);
+		var jwt = new SignedJWT(hb.build(), claims);
+		jwt.sign(new RSASSASigner(key));
+		return jwt.serialize();
+	}
+
+	/** Extracts a single query parameter value from a URL string (URL-decoded). */
+	static String queryParam(String url, String name) {
+		var q = URI.create(url).getQuery();
+		if (q == null)
+			return null;
+		for (var pair : q.split("&")) {
+			var i = pair.indexOf('=');
+			var k = i < 0 ? pair : pair.substring(0, i);
+			if (k.equals(name))
+				return i < 0 ? "" : URLDecoder.decode(pair.substring(i + 1), StandardCharsets.UTF_8);
+		}
+		return null;
+	}
+
+	/** Parses the cookie value out of a {@code Set-Cookie} header (the bit between {@code =} and the first {@code ;}). */
+	static String cookieValue(String setCookieHeader) {
+		if (setCookieHeader == null)
+			return null;
+		var eq = setCookieHeader.indexOf('=');
+		var semi = setCookieHeader.indexOf(';');
+		return setCookieHeader.substring(eq + 1, semi < 0 ? setCookieHeader.length() : semi);
+	}
+
+	/**
+	 * In-JVM stub IdP exposing a {@code /token} and {@code /userinfo} endpoint over loopback.
+	 *
+	 * <p>
+	 * The {@code /token} response is driven by mutable fields the test sets between flow steps (the ID
+	 * token must echo the nonce the relying party generated, which is only known after {@code startLogin}).
+	 */
+	static final class StubIdp implements AutoCloseable {
+
+		private final HttpServer server;
+		final String issuer = "https://stub-idp.example.com";
+
+		/** The ID token returned by the next {@code /token} call. */
+		volatile String idToken;
+		/** The refresh token returned by an authorization-code {@code /token} call. */
+		volatile String refreshToken = "rt-1";
+		/** The refresh token returned by a refresh-grant {@code /token} call (rotation). */
+		volatile String rotatedRefreshToken = "rt-2";
+		/** When true, the {@code /token} endpoint returns an error (simulates revoked / reused refresh token). */
+		volatile boolean failToken;
+		/** Extra claims served by {@code /userinfo}. */
+		volatile Map<String,Object> userInfo = new LinkedHashMap<>();
+
+		StubIdp() throws IOException {
+			server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+			server.createContext("/token", this::handleToken);
+			server.createContext("/userinfo", this::handleUserInfo);
+			server.start();
+		}
+
+		private void handleToken(HttpExchange ex) throws IOException {
+			var body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+			if (failToken) {
+				writeJson(ex, 400, "{\"error\":\"invalid_grant\"}");
+				return;
+			}
+			var isRefresh = body.contains("grant_type=refresh_token");
+			var rt = isRefresh ? rotatedRefreshToken : refreshToken;
+			var json = new StringBuilder("{")
+				.append("\"access_token\":\"at-").append(isRefresh ? "refreshed" : "initial").append("\",")
+				.append("\"token_type\":\"Bearer\",")
+				.append("\"expires_in\":3600,");
+			if (rt != null)
+				json.append("\"refresh_token\":\"").append(rt).append("\",");
+			if (idToken != null)
+				json.append("\"id_token\":\"").append(idToken).append("\",");
+			json.append("\"scope\":\"openid profile\"}");
+			writeJson(ex, 200, json.toString());
+		}
+
+		private void handleUserInfo(HttpExchange ex) throws IOException {
+			var sb = new StringBuilder("{\"sub\":\"alice\"");
+			userInfo.forEach((k, v) -> sb.append(",\"").append(k).append("\":\"").append(v).append("\""));
+			sb.append("}");
+			writeJson(ex, 200, sb.toString());
+		}
+
+		private static void writeJson(HttpExchange ex, int status, String body) throws IOException {
+			var bytes = body.getBytes(StandardCharsets.UTF_8);
+			ex.getResponseHeaders().add("Content-Type", "application/json");
+			ex.sendResponseHeaders(status, bytes.length);
+			try (var os = ex.getResponseBody()) {
+				os.write(bytes);
+			}
+		}
+
+		int port() {
+			return server.getAddress().getPort();
+		}
+
+		URI baseUri() {
+			return URI.create("http://127.0.0.1:" + port());
+		}
+
+		URI tokenEndpoint() {
+			return URI.create(baseUri() + "/token");
+		}
+
+		URI userinfoEndpoint() {
+			return URI.create(baseUri() + "/userinfo");
+		}
+
+		/** Builds an {@link OidcMetadata} record pointing the token/userinfo endpoints at this stub. */
+		OidcMetadata metadata(URI authorizationEndpoint, URI endSessionEndpoint) {
+			return new OidcMetadata(
+				URI.create(issuer),
+				tokenEndpoint(),
+				authorizationEndpoint,
+				null,
+				URI.create(baseUri() + "/jwks"),
+				userinfoEndpoint(),
+				endSessionEndpoint,
+				Set.of("openid", "profile", "email"),
+				Map.of());
+		}
+
+		@Override
+		public void close() {
+			server.stop(0);
+		}
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java
new file mode 100644
index 0000000000..c539bb26cc
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java
@@ -0,0 +1,107 @@
+/*
+ * 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.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SignedCookieSessionStore} &mdash; stateless HMAC-signed cookie round-trip, tamper
+ * rejection, expiry, and the size cap.
+ *
+ * @since 9.5.0
+ */
+class SignedCookieSessionStore_Test extends TestBase {
+
+	private static final String KEY = "0123456789abcdef0123456789abcdef";  // 32 bytes
+
+	private static OidcSession session(Instant now, String sid) {
+		return new OidcSession("id-1", "alice", Optional.ofNullable(sid),
+			new ClaimsPrincipal("alice", Map.of("sub", "alice", "email", "alice@example.com")),
+			new LinkedHashSet<>(List.of("user", "admin")),
+			Optional.empty(), now, now.plus(Duration.ofHours(8)));
+	}
+
+	@Test void a01_roundTrip_recoversClaimsAndRoles() {
+		var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+		var now = Instant.now();
+		var cookie = store.createSessionCookieValue(session(now, "sess-1"));
+		var s = store.lookup(cookie);
+		assertTrue(s.isPresent());
+		assertEquals("alice", s.get().subject());
+		assertEquals("alice", s.get().principal().getName());
+		assertEquals(Set.of("user", "admin"), s.get().roles());
+		assertEquals("sess-1", s.get().sid().orElse(null));
+		assertEquals("alice@example.com", s.get().principal().getClaim("email", String.class).orElse(null));
+		assertTrue(s.get().token().isEmpty(), "cookie store must NOT retain raw tokens");
+	}
+
+	@Test void b01_tamperedCookie_isRejected() {
+		var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+		var cookie = store.createSessionCookieValue(session(Instant.now(), null));
+		var tampered = cookie.substring(0, cookie.length() - 3) + "AAA";
+		assertTrue(store.lookup(tampered).isEmpty());
+	}
+
+	@Test void b02_wrongKey_isRejected() {
+		var signer = SignedCookieSessionStore.create().signingKey(KEY).build();
+		var cookie = signer.createSessionCookieValue(session(Instant.now(), null));
+		var other = SignedCookieSessionStore.create().signingKey("ffffffffffffffffffffffffffffffff").build();
+		assertTrue(other.lookup(cookie).isEmpty());
+	}
+
+	@Test void b03_garbageCookie_isRejected() {
+		var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+		assertTrue(store.lookup("not-a-jwt").isEmpty());
+	}
+
+	@Test void c01_expiredCookie_isRejected() {
+		var base = Instant.parse("2026-01-01T00:00:00Z");
+		var clock = new EphemeralStore_Test.MutableClock(base);
+		var store = SignedCookieSessionStore.create().signingKey(KEY).clock(clock).build();
+		var cookie = store.createSessionCookieValue(session(base, null));
+		clock.advance(Duration.ofHours(9));
+		assertTrue(store.lookup(cookie).isEmpty());
+	}
+
+	@Test void d01_doesNotSupportServerSideRevocation() {
+		var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+		assertFalse(store.supportsServerSideRevocation());
+		assertThrows(UnsupportedOperationException.class, () -> store.invalidateBySubject("alice"));
+		assertThrows(UnsupportedOperationException.class, () -> store.invalidateBySessionId("sess-1"));
+	}
+
+	@Test void e01_overCapPayload_throws() {
+		var store = SignedCookieSessionStore.create().signingKey(KEY).maxCookieBytes(128).build();
+		assertThrows(IllegalStateException.class,
+			() -> store.createSessionCookieValue(session(Instant.now(), "sess-1")));
+	}
+
+	@Test void f01_shortKey_rejected() {
+		assertThrows(IllegalArgumentException.class, () -> SignedCookieSessionStore.create().signingKey("too-short"));
+	}
+
+	@Test void f02_missingKey_rejected() {
+		assertThrows(IllegalStateException.class, () -> SignedCookieSessionStore.create().build());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
index 113853628f..fd48ab2401 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
@@ -41,7 +41,7 @@ class MetricsRecorder_Contract_Test extends TestBase {
 		public final List<Event> events = new CopyOnWriteArrayList<>();
 
 		@Override
-		public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+		public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
 			events.add(new Event(opName, httpMethod, uriTemplate, statusCode, elapsed, error));
 		}
 
@@ -175,7 +175,7 @@ class MetricsRecorder_Contract_Test extends TestBase {
 	}
 
 	@Test void d02_noOpRecorder_recordIsNoOp() {
-		assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record("op", "GET", "/x", 200, Duration.ofMillis(1), null));
-		assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record(null, null, null, 0, Duration.ZERO, new RuntimeException()));
+		assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record("op", "GET", "/x", 200, Duration.ofMillis(1), null, "", ""));
+		assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record(null, null, null, 0, Duration.ZERO, new RuntimeException(), "", ""));
 	}
 }
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
index c3479fb652..9238a07b18 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
@@ -69,7 +69,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
 	@Test void a05_happyPath_registersTimerWithExpectedTags() {
 		var registry = new SimpleMeterRegistry();
 		var r = new MicrometerMetricsRecorder(registry);
-		r.record("MyResource.get(java.lang.String)", "GET", "/users/{id}", 200, Duration.ofMillis(7), null);
+		r.record("MyResource.get(java.lang.String)", "GET", "/users/{id}", 200, Duration.ofMillis(7), null, "", "");
 
 		var timer = registry.find("http.server.requests")
 			.tag("method", "GET")
@@ -85,7 +85,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
 	@Test void a06_exceptionPath_recordsExceptionSimpleNameTag() {
 		var registry = new SimpleMeterRegistry();
 		var r = new MicrometerMetricsRecorder(registry);
-		r.record("op", "POST", "/orders", 500, Duration.ofMillis(3), new IllegalStateException());
+		r.record("op", "POST", "/orders", 500, Duration.ofMillis(3), new IllegalStateException(), "", "");
 
 		var timer = registry.find("http.server.requests")
 			.tag("exception", "IllegalStateException")
@@ -97,8 +97,8 @@ class MicrometerMetricsRecorder_Test extends TestBase {
 	@Test void a07_separateTagSets_produceSeparateTimers() {
 		var registry = new SimpleMeterRegistry();
 		var r = new MicrometerMetricsRecorder(registry);
-		r.record("op", "GET", "/a", 200, Duration.ofMillis(1), null);
-		r.record("op", "GET", "/b", 200, Duration.ofMillis(2), null);
+		r.record("op", "GET", "/a", 200, Duration.ofMillis(1), null, "", "");
+		r.record("op", "GET", "/b", 200, Duration.ofMillis(2), null, "", "");
 
 		assertEquals(1, registry.find("http.server.requests").tag("uri", "/a").timer().count());
 		assertEquals(1, registry.find("http.server.requests").tag("uri", "/b").timer().count());
@@ -107,7 +107,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
 	@Test void a08_blankUri_emitsEmptyStringTag() {
 		var registry = new SimpleMeterRegistry();
 		var r = new MicrometerMetricsRecorder(registry);
-		r.record("op", "GET", "", 200, Duration.ofMillis(1), null);
+		r.record("op", "GET", "", 200, Duration.ofMillis(1), null, "", "");
 		assertNotNull(registry.find("http.server.requests").tag("uri", "").timer());
 	}
 
@@ -168,7 +168,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
 	@Test void c01_prometheusScrapeContainsExpectedSample() {
 		var registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT, new PrometheusRegistry(), io.micrometer.core.instrument.Clock.SYSTEM);
 		var r = new MicrometerMetricsRecorder(registry);
-		r.record("op", "GET", "/users/{id}", 200, Duration.ofMillis(5), null);
+		r.record("op", "GET", "/users/{id}", 200, Duration.ofMillis(5), null, "", "");
 
 		String scrape = registry.scrape();
 		assertTrue(scrape.contains("http_server_requests_seconds_count"), "scrape output: " + scrape);
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java
new file mode 100644
index 0000000000..087cdbc3e7
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java
@@ -0,0 +1,251 @@
+/*
+ * 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.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.metrics.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for TODO-115: per-resource / per-op observability opt-in attributes.
+ *
+ * <ul>
+ *   <li>A — {@code @Rest(observability="false")} suppresses all metrics for the resource.
+ *   <li>B — {@code @RestGet(observability="false")} suppresses metrics for that op only.
+ *   <li>C — {@code @RestGet(observability="true")} on a resource that has no explicit resource-level setting
+ *            still fires metrics when a backend is wired.
+ *   <li>D — {@code @Rest(observability="false")} resource + {@code @RestGet(observability="true")} op override
+ *            re-enables observability for that specific op.
+ *   <li>E — Startup-fail when {@code @Rest(observability="true")} is set but no backend is wired.
+ *   <li>F — {@code @RestGet(metricName)} and {@code @RestGet(metricTags)} reach {@code record(...)}.
+ * </ul>
+ */
+class RestObservabilityAttributes_Test extends TestBase {
+
+	/** Recording MetricsRecorder that captures every event including metricName/metricTags. */
+	public static final class RecordingMetricsRecorder implements MetricsRecorder {
+		public final List<Event> events = new CopyOnWriteArrayList<>();
+
+		@Override
+		public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+			events.add(new Event(opName, httpMethod, uriTemplate, statusCode, elapsed, error, metricName, metricTags));
+		}
+
+		public Event last() { return events.get(events.size() - 1); }
+		public void clear() { events.clear(); }
+	}
+
+	public static final class Event {
+		public final String opName, httpMethod, uriTemplate, metricName, metricTags;
+		public final int statusCode;
+		public final Duration elapsed;
+		public final Throwable error;
+
+		Event(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+			this.opName = opName; this.httpMethod = httpMethod; this.uriTemplate = uriTemplate;
+			this.statusCode = statusCode; this.elapsed = elapsed; this.error = error;
+			this.metricName = metricName; this.metricTags = metricTags;
+		}
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// A: @Rest(observability="false") — metrics are suppressed for the entire resource.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder A_REC = new RecordingMetricsRecorder();
+
+	@Rest(observability = "false")
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return A_REC; }
+
+		@RestGet("/ping")
+		public String ping() { return "pong"; }
+	}
+
+	private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+	@Test void a01_resourceLevelOptOut_suppressesMetrics() throws Exception {
+		A_REC.clear();
+		CA.get("/ping").run().assertStatus(200);
+		assertEquals(0, A_REC.events.size(), "@Rest(observability='false') must suppress the observability block");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// B: @RestGet(observability="false") — metrics are suppressed for that specific op only.
+	//    Other ops on the same resource remain observable.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder B_REC = new RecordingMetricsRecorder();
+
+	@Rest
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return B_REC; }
+
+		@RestGet(path = "/silent", observability = "false")
+		public String silent() { return "no-metrics"; }
+
+		@RestGet("/loud")
+		public String loud() { return "metrics-fire"; }
+	}
+
+	private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+	@Test void b01_opLevelOptOut_suppressesMetricsForThatOp() throws Exception {
+		B_REC.clear();
+		CB.get("/silent").run().assertStatus(200);
+		assertEquals(0, B_REC.events.size(), "@RestGet(observability='false') must suppress that op's metrics");
+	}
+
+	@Test void b02_opLevelOptOut_doesNotAffectSiblingOps() throws Exception {
+		B_REC.clear();
+		CB.get("/loud").run().assertStatus(200);
+		assertEquals(1, B_REC.events.size(), "sibling op without observability opt-out must still record metrics");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// C: Default behavior (no observability attribute set) — metrics fire when a backend is wired.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder C_REC = new RecordingMetricsRecorder();
+
+	@Rest
+	public static class C extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return C_REC; }
+
+		@RestGet("/check")
+		public String check() { return "ok"; }
+	}
+
+	private static final MockRestClient CC = MockRestClient.buildLax(C.class);
+
+	@Test void c01_defaultBehavior_metricsFireWhenBackendWired() throws Exception {
+		C_REC.clear();
+		CC.get("/check").run().assertStatus(200);
+		assertEquals(1, C_REC.events.size(), "default (no observability attribute) must fire metrics when backend wired");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// D: @Rest(observability="false") resource + @RestGet(observability="true") op override.
+	//    The op-level "true" re-enables observability for that specific op.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder D_REC = new RecordingMetricsRecorder();
+
+	@Rest(observability = "false")
+	public static class D extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return D_REC; }
+
+		@RestGet(path = "/silent", observability = "false")
+		public String silent() { return "still-silent"; }
+
+		@RestGet(path = "/loud", observability = "true")
+		public String loud() { return "re-enabled"; }
+	}
+
+	private static final MockRestClient CD = MockRestClient.buildLax(D.class);
+
+	@Test void d01_opLevelOptIn_overridesResourceLevelOptOut() throws Exception {
+		D_REC.clear();
+		CD.get("/loud").run().assertStatus(200);
+		assertEquals(1, D_REC.events.size(), "@RestGet(observability='true') must re-enable metrics even when resource says false");
+	}
+
+	@Test void d02_opLevelOptOut_still_suppressesWhenResourceAlreadySilent() throws Exception {
+		D_REC.clear();
+		CD.get("/silent").run().assertStatus(200);
+		assertEquals(0, D_REC.events.size(), "@RestGet(observability='false') on resource with observability='false' must suppress");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// E: Startup-fail — @Rest(observability="true") but no backend wired must throw during context build.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_startupFail_whenObservabilityTrueAndNoBackend() {
+		@Rest(observability = "true")
+		class EResource extends RestServlet {
+			private static final long serialVersionUID = 1L;
+			@RestGet("/x") public String x() { return "x"; }
+		}
+		assertThrows(Exception.class, () -> MockRestClient.buildLax(EResource.class),
+			"@Rest(observability='true') with no MetricsRecorder or TracerHook must fail at build time");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// F: metricName and metricTags are wired through to record().
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder F_REC = new RecordingMetricsRecorder();
+
+	@Rest
+	public static class F extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return F_REC; }
+
+		@RestGet(path = "/tagged", metricName = "custom.timer", metricTags = "team=payments,region=us-east")
+		public String tagged() { return "tagged"; }
+
+		@RestGet("/plain")
+		public String plain() { return "plain"; }
+	}
+
+	private static final MockRestClient CF = MockRestClient.buildLax(F.class);
+
+	@Test void f01_metricName_reachesRecord() throws Exception {
+		F_REC.clear();
+		CF.get("/tagged").run().assertStatus(200);
+		assertEquals(1, F_REC.events.size());
+		assertEquals("custom.timer", F_REC.last().metricName, "metricName must reach MetricsRecorder.record()");
+	}
+
+	@Test void f02_metricTags_reachesRecord() throws Exception {
+		F_REC.clear();
+		CF.get("/tagged").run().assertStatus(200);
+		assertEquals(1, F_REC.events.size());
+		assertEquals("team=payments,region=us-east", F_REC.last().metricTags, "metricTags must reach MetricsRecorder.record()");
+	}
+
+	@Test void f03_plainOp_emptyMetricNameAndTags() throws Exception {
+		F_REC.clear();
+		CF.get("/plain").run().assertStatus(200);
+		assertEquals(1, F_REC.events.size());
+		assertEquals("", F_REC.last().metricName, "metricName must be empty when not set");
+		assertEquals("", F_REC.last().metricTags, "metricTags must be empty when not set");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java
new file mode 100644
index 0000000000..0b010ccdf8
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java
@@ -0,0 +1,128 @@
+/*
+ * 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.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.metrics.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests SVL variable support in observability annotation attributes.
+ *
+ * <p>
+ * The resource-level {@code @Rest(observability)} attribute is resolved at {@code RestContext} build time
+ * (the constructor pre-warms the memoizer via the startup-fail check). Op-level attributes
+ * ({@code metricName}, {@code metricTags}) are resolved lazily on first request.
+ *
+ * <p>
+ * Note: {@code MockRestClient} caches the {@link org.apache.juneau.rest.RestContext} per resource class.
+ * Each scenario uses a distinct static inner class so the SVL state at build time is deterministic.
+ */
+class RestObservabilitySvl_Test extends TestBase {
+
+	public static final class RecordingMetricsRecorder implements MetricsRecorder {
+		public final List<Event> events = new CopyOnWriteArrayList<>();
+
+		@Override
+		public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+			events.add(new Event(metricName, metricTags));
+		}
+
+		public Event last() { return events.get(events.size() - 1); }
+		public void clear() { events.clear(); }
+	}
+
+	public static final class Event {
+		public final String metricName, metricTags;
+		Event(String metricName, String metricTags) {
+			this.metricName = metricName; this.metricTags = metricTags;
+		}
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// A: metricName and metricTags with literal values flow correctly to record().
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder A_REC = new RecordingMetricsRecorder();
+
+	@Rest
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return A_REC; }
+
+		@RestGet(path = "/op", metricName = "my.service.requests", metricTags = "svc=orders")
+		public String op() { return "ok"; }
+	}
+
+	private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+	@Test void a01_literalMetricNameAndTags_resolvedCorrectly() throws Exception {
+		A_REC.clear();
+		CA.get("/op").run().assertStatus(200);
+		assertEquals("my.service.requests", A_REC.last().metricName,
+			"literal metricName must reach MetricsRecorder.record() unchanged");
+		assertEquals("svc=orders", A_REC.last().metricTags,
+			"literal metricTags must reach MetricsRecorder.record() unchanged");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// B: SVL variable in @Rest(observability) is resolved at RestContext build time.
+	//    When the system property resolves to "false", metrics are suppressed for the entire resource.
+	//    The static initializer sets the property before building the client and then clears it;
+	//    this works because the resource-level observability attribute IS resolved eagerly (the
+	//    startup-fail check pre-warms it inside the RestContext constructor).
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static final RecordingMetricsRecorder B_REC = new RecordingMetricsRecorder();
+
+	@Rest(observability = "$S{test.svl.obs.b,}")
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@org.apache.juneau.commons.inject.Bean
+		public MetricsRecorder recorder() { return B_REC; }
+
+		@RestGet("/ping")
+		public String ping() { return "pong"; }
+	}
+
+	// Build the client while the property is "false" so the context captures observability=false.
+	private static final MockRestClient CB;
+	static {
+		System.setProperty("test.svl.obs.b", "false");
+		CB = MockRestClient.buildLax(B.class);
+		System.clearProperty("test.svl.obs.b");
+	}
+
+	@Test void b01_svlObservability_resolvedAtBuildTime_suppressesMetrics() throws Exception {
+		B_REC.clear();
+		CB.get("/ping").run().assertStatus(200);
+		assertEquals(0, B_REC.events.size(),
+			"$S{} SVL variable resolving to 'false' at context build time must suppress observability block");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java
new file mode 100644
index 0000000000..a0bc0b23ba
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java
@@ -0,0 +1,202 @@
+/*
+ * 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.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.settings.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.httppart.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.tracing.*;
+import org.apache.juneau.rest.tracing.otel.*;
+import org.junit.jupiter.api.*;
+
+import io.opentelemetry.api.trace.propagation.*;
+import io.opentelemetry.context.propagation.*;
+import io.opentelemetry.sdk.*;
+import io.opentelemetry.sdk.testing.exporter.*;
+import io.opentelemetry.sdk.trace.*;
+import io.opentelemetry.sdk.trace.export.*;
+
+/**
+ * Tests for {@link TraceContextResponseProcessor} (TODO-114) — outgoing-response W3C trace-context
+ * header injection.
+ *
+ * <p>
+ * Reuses FINISHED-67's OpenTelemetry SDK + {@link InMemorySpanExporter} test-fixture pattern: a
+ * statically-wired SDK is installed before the {@code @Rest} resources build their
+ * {@link MockRestClient}s so the {@link OtelTracerHook} bridge captures the server-started span's
+ * context and stashes the rendered headers for the response processor to write.
+ */
+class TraceContextResponseProcessor_Test extends TestBase {
+
+	/** W3C {@code traceparent} value format: {@code version-traceId-spanId-flags}. */
+	private static final String W3C_TRACEPARENT_REGEX = "[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}";
+
+	// Static init so the SDK is wired before MockRestClient.buildLax() in the @Rest classes runs.
+	static final InMemorySpanExporter EXPORTER = InMemorySpanExporter.create();
+	static final OpenTelemetrySdk OTEL_SDK = OpenTelemetrySdk.builder()
+		.setTracerProvider(SdkTracerProvider.builder()
+			.addSpanProcessor(SimpleSpanProcessor.create(EXPORTER))
+			.build())
+		.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
+		.build();
+
+	@BeforeEach
+	void resetExporter() {
+		EXPORTER.reset();
+	}
+
+	@AfterEach
+	void cleanup() {
+		System.clearProperty("RestContext.responseTraceparent");
+		Settings.get().unsetGlobal("RestContext.responseTraceparent");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// A: Happy path — active tracer ⇒ traceparent header present and well-formed.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@Bean
+		public TracerHook tracer() { return new OtelTracerHook(OTEL_SDK); }
+
+		@RestGet("/users/{id}")
+		public String get(@Path String id) { return "u:" + id; }
+	}
+
+	private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+	@Test void a01_happyPath_traceparentHeaderPresent() throws Exception {
+		CA.get("/users/42").run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isExists();
+	}
+
+	@Test void a02_headerFormat_matchesW3cRegex() throws Exception {
+		CA.get("/users/99").run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isPattern(W3C_TRACEPARENT_REGEX);
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// B: NoOp tracer — no @Bean TracerHook ⇒ no traceparent header.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@RestGet("/ping")
+		public String ping() { return "pong"; }
+	}
+
+	private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+	@Test void b01_noTracer_noTraceparentHeader() throws Exception {
+		CB.get("/ping").run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isNull();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// C: Opt-out — RestContext.responseTraceparent=false keeps the processor unregistered even with a tracer.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Rest
+	public static class C extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@Bean
+		public TracerHook tracer() { return new OtelTracerHook(OTEL_SDK); }
+
+		@RestGet("/opt")
+		public String opt() { return "x"; }
+	}
+
+	@Test void c01_optOut_noTraceparentHeaderDespiteActiveTracer() throws Exception {
+		// Set BEFORE building the resource so the @Value-driven default is resolved as false at init.
+		System.setProperty("RestContext.responseTraceparent", "false");
+		var c = MockRestClient.buildLax(C.class);
+		c.get("/opt").run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isNull();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// D: tracestate companion header — emitted only when the active trace state is non-empty.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_incomingTracestate_emittedOnResponse() throws Exception {
+		var traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
+		CA.get("/users/7")
+			.header("traceparent", traceparent)
+			.header("tracestate", "foo=bar")
+			.run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isPattern(W3C_TRACEPARENT_REGEX)
+			.assertHeader("tracestate").is("foo=bar");
+	}
+
+	@Test void d02_emptyTracestate_headerAbsent() throws Exception {
+		// No incoming tracestate ⇒ server span carries an empty trace state ⇒ no tracestate header written.
+		CA.get("/users/8").run()
+			.assertStatus(200)
+			.assertHeader("traceparent").isExists()
+			.assertHeader("tracestate").isNull();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// F: Response-already-committed edge — processor skips without throwing (MockServletResponse.isCommitted() is
+	// hard-wired false, so this branch is exercised with mocks).
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_responseCommitted_skipsWithoutThrowing() {
+		var req = mock(RestRequest.class);
+		var res = mock(RestResponse.class);
+		var hsr = mock(jakarta.servlet.http.HttpServletResponse.class);
+		var opSession = mock(RestOpSession.class);
+
+		var tpAttr = mock(RequestAttribute.class);
+		when(tpAttr.as(String.class)).thenReturn(Optional.of("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"));
+		when(req.getAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT)).thenReturn(tpAttr);
+
+		when(opSession.getRequest()).thenReturn(req);
+		when(opSession.getResponse()).thenReturn(res);
+		when(res.getHttpServletResponse()).thenReturn(hsr);
+		when(hsr.isCommitted()).thenReturn(true);
+
+		var p = new TraceContextResponseProcessor();
+		var rc = assertDoesNotThrow(() -> p.process(opSession));
+		assertEquals(ResponseProcessor.NEXT, rc);
+		// Committed ⇒ no header write attempted.
+		verify(res, never()).setHeader(eq("traceparent"), anyString());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java
new file mode 100644
index 0000000000..0f2d6c350a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java
@@ -0,0 +1,217 @@
+/*
+ * 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.juneau.rest.processor;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.atomic.*;
+import java.util.function.*;
+
+import org.apache.juneau.TestBase;
+import org.junit.jupiter.api.*;
+import org.slf4j.*;
+
+/**
+ * Tests for {@link MdcAsyncListener} — the SLF4J MDC bridge that propagates request-thread MDC to
+ * {@link CompletableFuture} completion threads (TODO-117).
+ *
+ * <p>
+ * All direct unit tests exercise {@link MdcAsyncListener} in isolation via its static factory methods.
+ * The integration tests verify the builder-knob opt-out path through the {@link AsyncResponseProcessor}
+ * stack using the {@code MockRestClient} harness.
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+class MdcAsyncListener_Test extends TestBase {
+
+	@AfterEach
+	void clearMdc() {
+		MDC.clear();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// MdcAsyncListener_HappyPath: snapshot / restore works — callback sees request-thread MDC.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test
+	void happyPath_01_slfjAvailable() {
+		assertTrue(MdcAsyncListener.isAvailable(), "SLF4J MDC must be on the test classpath");
+	}
+
+	@Test
+	void happyPath_02_snapshotCapturesCurrentMdc() {
+		MDC.put("requestId", "abc-123");
+		MDC.put("userId", "alice");
+
+		var snap = MdcAsyncListener.snapshot();
+
+		assertNotNull(snap);
+		assertEquals("abc-123", snap.get("requestId"));
+		assertEquals("alice", snap.get("userId"));
+	}
+
+	@Test
+	void happyPath_03_wrappedCallbackSeesMdc() {
+		MDC.put("requestId", "req-999");
+		var snap = MdcAsyncListener.snapshot();
+
+		MDC.clear();  // Simulate moving to a fresh completion thread.
+
+		var capturedRequestId = new AtomicReference<String>();
+		var wrapped = MdcAsyncListener.<String>wrap(
+			(v, e) -> capturedRequestId.set(MDC.get("requestId")),
+			snap
+		);
+
+		wrapped.accept("result", null);
+
+		assertEquals("req-999", capturedRequestId.get(), "Completion-thread callback must see the request-thread MDC.");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// MdcAsyncListener_NoSlf4j: when snapshot is null (empty MDC), wrap returns original action.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test
+	void noSlf4j_01_emptyMdcSnapshotIsNull() {
+		// MDC is clear — snapshot() must return null (lazy-skip contract).
+		assertNull(MdcAsyncListener.snapshot());
+	}
+
+	@Test
+	void noSlf4j_02_nullSnapshotReturnsOriginalAction() {
+		var original = (java.util.function.BiConsumer<String, Throwable>) (v, e) -> {};
+		var wrapped = MdcAsyncListener.wrap(original, null);
+		assertSame(original, wrapped, "wrap(action, null) must return the original action unchanged.");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// MdcAsyncListener_OptOut: builder knob mdcAsyncPropagation(false) disables propagation.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test
+	void optOut_01_nullSnapshotReturnsOriginalAction() {
+		// When propagation is disabled (or request thread has no MDC), the snapshot is null
+		// and wrap() returns the original action with no overhead.
+		var original = (java.util.function.BiConsumer<String, Throwable>) (v, e) -> {};
+		assertSame(original, MdcAsyncListener.wrap(original, null),
+			"wrap(action, null) must return the original action unchanged when propagation is off.");
+	}
+
+	@Test
+	void optOut_02_disabledPropagationDoesNotRestoreMdc() {
+		// Simulate disabled propagation: no snapshot is taken, wrap is called with null.
+		MDC.put("requestId", "should-not-propagate");
+		var requestSnap = MdcAsyncListener.snapshot();
+		MDC.clear();  // Completion thread starts empty.
+
+		// opt-out: pretend the code decided not to propagate — it would pass null to wrap().
+		var capturedOnCompletionThread = new AtomicReference<String>();
+		MdcAsyncListener.<String>wrap(
+			(v, e) -> capturedOnCompletionThread.set(MDC.get("requestId")),
+			/* disabled: */ null
+		).accept("result", null);
+
+		assertNull(capturedOnCompletionThread.get(),
+			"Completion callback must not see MDC when propagation is disabled (null snapshot).");
+		// Snapshot was captured earlier just to consume it; not passed to wrap.
+		assertNotNull(requestSnap);  // Confirm MDC was set on request thread.
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// MdcAsyncListener_Cleanup: MDC cleared from completion thread after callback (no thread-pool leak).
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test
+	void cleanup_01_mdcClearedAfterCallback() {
+		MDC.put("requestId", "to-be-cleared");
+		var snap = MdcAsyncListener.snapshot();
+		MDC.clear();  // Completion thread starts with no MDC.
+
+		MdcAsyncListener.<String>wrap((v, e) -> {}, snap).accept("ok", null);
+
+		// After the wrapped callback completes, the completion thread's MDC must be empty —
+		// no leak into the next task in the thread pool.
+		assertNull(MDC.get("requestId"), "MDC must be cleared after the wrapped callback runs.");
+		assertTrue(MDC.getCopyOfContextMap() == null || MDC.getCopyOfContextMap().isEmpty(),
+			"Completion thread must have an empty MDC after callback to prevent thread-pool contamination.");
+	}
+
+	@Test
+	void cleanup_02_preExistingCompletionThreadMdcIsPreserved() {
+		// Simulate a completion thread that already has its own MDC entries.
+		MDC.put("ownKey", "ownValue");
+		var completionThreadSnap = MdcAsyncListener.snapshot();  // Save for comparison.
+
+		// Request-thread snapshot (a different set of keys).
+		var requestSnap = Map.of("requestId", "req-789");
+
+		// After wrap completes, the completion thread's original MDC should be restored.
+		MdcAsyncListener.<String>wrap((v, e) -> {
+			assertEquals("req-789", MDC.get("requestId"), "Request MDC must be visible during callback.");
+		}, requestSnap).accept("ok", null);
+
+		// After callback: completion thread's original "ownKey" must be back; "requestId" must be gone.
+		assertEquals("ownValue", MDC.get("ownKey"), "Completion thread's own MDC must be restored.");
+		assertNull(MDC.get("requestId"), "Request-thread MDC must not leak into the completion thread.");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// MdcAsyncListener_ExceptionalCompletion: MDC still set during failure callback, and cleared after.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test
+	void exceptionalCompletion_01_mdcVisibleOnErrorPath() {
+		MDC.put("requestId", "err-path");
+		var snap = MdcAsyncListener.snapshot();
+		MDC.clear();
+
+		var capturedOnError = new AtomicReference<String>();
+		var wrapped = MdcAsyncListener.<String>wrap(
+			(v, e) -> capturedOnError.set(MDC.get("requestId")),
+			snap
+		);
+
+		// Simulate exceptional completion: value is null, throwable is present.
+		wrapped.accept(null, new RuntimeException("simulated failure"));
+
+		assertEquals("err-path", capturedOnError.get(), "MDC must be set during exceptional-completion callback.");
+		// Cleanup must still have run.
+		assertNull(MDC.get("requestId"), "MDC must be cleared after exceptional-completion callback.");
+	}
+
+	@Test
+	void exceptionalCompletion_02_mdcClearedEvenWhenCallbackThrows() {
+		MDC.put("requestId", "throw-path");
+		var snap = MdcAsyncListener.snapshot();
+		MDC.clear();
+
+		var wrapped = MdcAsyncListener.<String>wrap(
+			(v, e) -> { throw new RuntimeException("callback itself throws"); },
+			snap
+		);
+
+		// The wrapped BiConsumer must NOT re-throw (or if it does, MDC is cleared in finally).
+		// BiConsumer.accept is not declared to throw, so the RuntimeException propagates — but
+		// the important thing is the MDC is cleared even if it does.
+		RuntimeException thrown = assertThrows(RuntimeException.class, () -> wrapped.accept("x", null));
+		assertEquals("callback itself throws", thrown.getMessage());
+
+		// MDC must be cleared even though the callback threw.
+		assertNull(MDC.get("requestId"), "MDC must be cleared in the finally block even when callback throws.");
+	}
+}
diff --git a/juneau-utest/test-run-history.tsv b/juneau-utest/test-run-history.tsv
index 4818b939d6..8d62b9d16b 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -52,3 +52,4 @@ timestamp	git_sha	branch	tests_run	failures	errors	skipped	surefire_sec	wall_sec
 2026-05-28T20:45:00Z	007293082747	master	125891	0	0	21	146
 2026-05-28T21:29:51Z	b0dc55154f95	master	125891	0	0	21	143
 2026-05-29T10:45:54Z	3dc947b24d41	master	125897	0	0	21	151
+2026-05-29T13:48:36Z	c59fc14fac5e	master	126037	0	0	21	144