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 f2932362a6 Measure MCP schema-safety compute budget as thread CPU 
time; add view-mixin responseProcessor auto-fold opt-in; FreeMarker 
object-wrapper + fluent ConfigItem.setValue
f2932362a6 is described below

commit f2932362a6158e2d9fb2ed109d35866c4f0720e9
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 13 18:03:16 2026 -0400

    Measure MCP schema-safety compute budget as thread CPU time; add view-mixin 
responseProcessor auto-fold opt-in; FreeMarker object-wrapper + fluent 
ConfigItem.setValue
    
    McpSchemaSafety (mcp-v20260728): the tools/call input-schema-validation DoS 
budget is now charged against the validating thread's actual CPU time 
(ThreadMXBean.getThreadCpuTime) instead of wall-clock, so OS 
preemption/scheduling no longer counts against the ~100ms budget. Includes a 
support-check with a verbatim wall-clock fallback when thread-CPU timing is 
unavailable, correct ns/-1-sentinel handling, and sleep-vs-CPU-burn regression 
tests. Fixes an intermittent CI false-trip (-32602 [...]
    
    TODO-358 (juneau-rest-server view mixins): (1) FreemarkerDispatcher's 
bridge-default Configuration now installs a 
DefaultObjectWrapper(exposeFields=true) so public-field DTOs render instead of 
silently resolving to null, with new 
exposeFields(boolean)/objectWrapper(ObjectWrapper) builder knobs. (2) New 
non-silent, response-processor-scoped 
@Rest(mergeResponseProcessorsIntoHost=true) opt-in: a plain 
@Rest(mixins=<ViewMixin>.class) now folds the mixin's renderer into the host 
chain so t [...]
    
    TODO-320 (juneau-sc-server): ConfigItem.setValue(String) is now a fluent 
self-returning setter, matching the repo-wide convention (the lone void-setter 
outlier); source-compatible, no reflective contract broken.
    
    Docs: release notes for both behavioral changes, plus FreeMarker/JSP 
view-support and mixin-subcontexts topic updates.
---
 .../rest/server/mcp/v20260728/McpSchemaSafety.java | 169 +++++++++++++++++----
 .../server/mcp/v20260728/McpSchemaSafety_Test.java |  96 +++++++++++-
 .../view/freemarker/FreemarkerDispatcher.java      | 107 ++++++++++++-
 .../server/view/freemarker/FreemarkerMixin.java    | 142 ++++++++++++++++-
 .../view/freemarker/FreemarkerDispatcher_Test.java |  53 +++++++
 .../freemarker/FreemarkerMixin_Builder_Test.java   |  30 ++++
 .../juneau/rest/server/view/jsp/JspMixin.java      |  39 +++--
 .../jsp/JspViewRenderer_ForwardPaths_Test.java     |  79 +++++++---
 .../rest/server/view/mustache/MustacheMixin.java   |  13 +-
 .../rest/server/view/thymeleaf/ThymeleafMixin.java |  13 +-
 .../java/org/apache/juneau/rest/server/Rest.java   |  31 ++++
 .../apache/juneau/rest/server/RestAnnotation.java  |  19 +++
 .../org/apache/juneau/rest/server/RestContext.java |  51 ++++++-
 .../server/MixinResponseProcessorFold_Test.java    | 139 +++++++++++++++++
 .../server/config/repository/ConfigItem.java       |   3 +-
 15 files changed, 888 insertions(+), 96 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
index d3c8dee7a1..c17065ab14 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
 
 import static java.util.concurrent.TimeUnit.*;
 
+import java.lang.management.*;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
@@ -49,12 +50,28 @@ import org.apache.juneau.rest.server.mcp.McpSchema;
  * <p>
  * Validation itself runs on a fixed-size pool of daemon threads and is 
bounded by the same overall budget: if
  * a pathological schema (for example a catastrophically-backtracking {@code 
pattern}) fails to complete in
- * time, the task is cancelled and a {@code -32602} error is raised instead of 
hanging the request thread. That
- * compute budget is measured from the moment the task actually starts 
running, not from the moment it is
- * submitted: time spent waiting for a free thread in {@link #VALIDATION_POOL} 
(for example under heavy
- * concurrent build/test load) is scheduling latency, not validation cost, so 
it is never charged against
- * {@link #MAX_VALIDATION_MILLIS}. A separate, much more generous {@link 
#MAX_SCHEDULING_MILLIS} backstop still
- * bounds the scheduling wait itself, purely so a wedged or saturated pool 
cannot block the caller forever.
+ * time, the task is cancelled and a {@code -32602} error is raised instead of 
hanging the request thread.
+ *
+ * <p>
+ * <b>The compute budget is charged against the validating thread's actual CPU 
time, not wall-clock time.</b>
+ * The DoS threat being defended against is a schema that burns CPU 
(catastrophic backtracking, quadratic
+ * blowups); the amount of CPU a validation consumes is exactly what that 
budget should cap. Measuring
+ * wall-clock instead would wrongly count time the validating thread was 
<i>not</i> running - time it spent
+ * preempted by the OS scheduler, or queued behind sibling work - as if it 
were validation cost. Under heavy
+ * concurrent load (for example {@code juneau-integration-tests} running 
Surefire with {@code forkCount=8}) that
+ * false accounting can trip the budget on trivial input, and the same 
false-positive can bite a saturated
+ * production deployment. Sampling {@link ThreadMXBean#getThreadCpuTime(long) 
thread CPU time} on the thread
+ * that actually does the work means only real compute counts: preemption and 
scheduling latency no longer
+ * shrink the budget, while a genuinely expensive validation still trips it 
and returns the same {@code -32602}
+ * error. On a JVM where per-thread CPU timing is unavailable, {@link 
#awaitBounded} transparently falls back to
+ * the original wall-clock measurement so the guard still functions everywhere.
+ *
+ * <p>
+ * Independently of the compute budget, time spent waiting for a free thread 
in {@link #VALIDATION_POOL} is
+ * scheduling latency, not validation cost, so it is never charged against 
{@link #MAX_VALIDATION_MILLIS}: the
+ * budget clock (CPU or wall-clock) only starts once the task is actually 
running. A separate, much more
+ * generous {@link #MAX_SCHEDULING_MILLIS} backstop bounds the scheduling wait 
itself, purely so a wedged or
+ * saturated pool cannot block the caller forever.
  */
 final class McpSchemaSafety {
 
@@ -65,10 +82,10 @@ final class McpSchemaSafety {
        static final int MAX_NODES = 10_000;
 
        /**
-        * Maximum compute time permitted for a single schema validation, in 
milliseconds, measured from the
-        * moment the validation task actually starts running on {@link 
#VALIDATION_POOL} (not from submission).
+        * Maximum compute time permitted for a single schema validation, in 
milliseconds, measured as the
+        * validating thread's actual CPU time (see class-level notes) - not 
from submission, and not as wall-clock.
         * This is the DoS bound: it caps how much CPU a pathological schema 
can burn, and is deliberately
-        * unaffected by how long the task had to wait for a free thread.
+        * unaffected both by how long the task had to wait for a free thread 
and by any OS preemption while it runs.
         */
        static final long MAX_VALIDATION_MILLIS = 100;
 
@@ -80,6 +97,36 @@ final class McpSchemaSafety {
         */
        static final long MAX_SCHEDULING_MILLIS = 2_000;
 
+       private static final ThreadMXBean THREAD_MX = 
ManagementFactory.getThreadMXBean();
+
+       /**
+        * Whether per-thread CPU-time measurement is available (and enabled) 
on this JVM. When <jk>true</jk>, the
+        * compute budget is charged against the validating thread's actual CPU 
time; when <jk>false</jk> (a JVM that
+        * cannot report per-thread CPU time), {@link #awaitBounded} falls back 
to the original wall-clock measurement
+        * so the DoS guard still functions everywhere.
+        */
+       private static final boolean CPU_TIME_SUPPORTED = initCpuTimeSupport();
+
+       /**
+        * How often the waiting thread re-samples the validating thread's 
accumulated CPU time while a validation is
+        * still running. Small enough that a runaway validation is stopped 
promptly once it burns past the budget,
+        * large enough that the sampling overhead is negligible.
+        */
+       private static final long CPU_POLL_INTERVAL_NANOS = 
MILLISECONDS.toNanos(5);
+
+       private static boolean initCpuTimeSupport() {
+               if (! THREAD_MX.isThreadCpuTimeSupported())
+                       return false;  // HTT: CI JVMs support per-thread CPU 
time; this fallback path isn't reachable there.
+               if (! THREAD_MX.isThreadCpuTimeEnabled()) {
+                       try {
+                               THREAD_MX.setThreadCpuTimeEnabled(true);  // 
HTT: HotSpot enables thread CPU time by default; not reachable in CI.
+                       } catch (@SuppressWarnings("unused") SecurityException 
| UnsupportedOperationException e) {
+                               return false;  // HTT: enabling is rejected 
only under a restrictive SecurityManager; not reachable in CI.
+                       }
+               }
+               return true;
+       }
+
        private static final ExecutorService VALIDATION_POOL = 
newValidationPool();
 
        private static ExecutorService newValidationPool() {
@@ -125,56 +172,79 @@ final class McpSchemaSafety {
 
        /**
         * Runs {@link JsonSchemaValidator} on a daemon thread and enforces the 
remaining share of the shared
-        * {@link JsonValueSafety} deadline against the task's own compute time 
- never against however long it
-        * had to wait in {@link #VALIDATION_POOL} for a free thread.
+        * {@link JsonValueSafety} deadline against the task's own compute time 
- never against however long it had
+        * to wait in {@link #VALIDATION_POOL} for a free thread, and (when CPU 
timing is available) never against
+        * wall-clock time it spent preempted rather than running.
         */
        private static void validateBounded(JsonSchema<?> schema, Object value, 
long deadlineNanos) {
                var remaining = JsonValueSafety.remainingNanos(deadlineNanos);
                if (remaining == 0)
                        throw validationTimeoutException();
                var started = new CountDownLatch(1);
-               var startedAtNanos = new AtomicLong();
+               var taskStart = new AtomicReference<TaskStart>();
                var future = VALIDATION_POOL.submit(() -> {
-                       startedAtNanos.set(System.nanoTime());
+                       taskStart.set(TaskStart.capture());
                        started.countDown();
                        JsonSchemaValidator.of(schema).validate(value);
                        return null;
                });
-               awaitBounded(future, started, startedAtNanos, remaining);
+               awaitBounded(future, started, taskStart, remaining);
        }
 
        /**
-        * Waits for a submitted validation task to complete, charging only its 
own compute time - measured from
-        * {@code startedAtNanos}, which the task sets as its first instruction 
- against {@code remaining}, the
-        * caller's share of the shared {@link JsonValueSafety} deadline.
+        * Snapshot of when a validation task actually began running, captured 
on the validating thread itself as its
+        * first instruction.
+        *
+        * @param threadId The validating thread's id, so the waiting thread 
can sample that same thread's CPU time.
+        * @param wallNanos The {@link System#nanoTime()} baseline, used by the 
wall-clock fallback.
+        * @param cpuNanos The validating thread's CPU-time baseline in 
nanoseconds ({@link ThreadMXBean#getThreadCpuTime(long)}),
+        *      or {@code -1} if per-thread CPU timing is unavailable on this 
JVM.
+        */
+       record TaskStart(long threadId, long wallNanos, long cpuNanos) {
+
+               /** Captures the current thread's start snapshot; must be 
called on the thread that runs the validation. */
+               static TaskStart capture() {
+                       var id = Thread.currentThread().getId();
+                       return new TaskStart(id, System.nanoTime(), 
CPU_TIME_SUPPORTED ? THREAD_MX.getThreadCpuTime(id) : -1L);
+               }
+       }
+
+       /**
+        * Waits for a submitted validation task to complete, charging only its 
own compute time against
+        * {@code remaining} (the caller's share of the shared {@link 
JsonValueSafety} deadline) - never however long
+        * it had to wait in {@link #VALIDATION_POOL} for a free thread.
         *
         * <p>
-        * This waits in two phases. First, {@code started} is awaited so the 
calling thread learns exactly when
-        * the task begins running; this wait is bounded only by the generous 
{@link #MAX_SCHEDULING_MILLIS}
-        * backstop, since scheduling delay under load (queueing behind other 
work in {@link #VALIDATION_POOL})
-        * is not the thing being defended against. Second, once running, the 
task is given the full
-        * {@code remaining} share of the deadline as its own fresh compute 
window - anchored to its actual start
-        * time rather than to submission time - so a busy pool cannot eat into 
the budget that is meant to cap
-        * the validator's own work.
+        * This waits in two phases. First, {@code started} is awaited so the 
calling thread learns exactly when the
+        * task begins running; this wait is bounded only by the generous 
{@link #MAX_SCHEDULING_MILLIS} backstop,
+        * since scheduling delay under load (queueing behind other work in 
{@link #VALIDATION_POOL}) is not the thing
+        * being defended against. Second, once the task is running, its 
compute is bounded: when per-thread CPU
+        * timing is available (the normal case) the waiting thread polls the 
validating thread's accumulated
+        * <i>CPU</i> time and trips only if that exceeds {@code remaining}, so 
preemption and scheduling latency
+        * never shrink the budget - only real CPU work does. When CPU timing 
is unavailable, it falls back to the
+        * original wall-clock window anchored to the task's actual start time.
         *
         * <p>
         * Package-private (rather than private) purely so this can be 
exercised directly against a test-local
-        * {@link Future}/latch pair - deterministically simulating scheduling 
delay - without needing to saturate
-        * the shared {@link #VALIDATION_POOL}.
+        * {@link Future}/latch pair - deterministically simulating scheduling 
delay, a CPU burn, or a sleep that
+        * elapses wall-clock without consuming CPU - without needing to 
saturate the shared {@link #VALIDATION_POOL}.
         *
         * @param future The in-flight (or already-complete) validation task.
         * @param started Counted down by the task as its first instruction, 
once it actually begins running.
-        * @param startedAtNanos Set by the task (before counting down {@code 
started}) to {@link System#nanoTime()}.
+        * @param taskStart Set by the task (before counting down {@code 
started}) to its {@link TaskStart} snapshot.
         * @param remaining The caller's remaining share of the shared 
deadline, in nanoseconds, captured before submission.
         */
-       static void awaitBounded(Future<?> future, CountDownLatch started, 
AtomicLong startedAtNanos, long remaining) {
+       static void awaitBounded(Future<?> future, CountDownLatch started, 
AtomicReference<TaskStart> taskStart, long remaining) {
                try {
                        if (! started.await(MAX_SCHEDULING_MILLIS, 
MILLISECONDS)) {
                                future.cancel(true);
                                throw new 
McpException(McpRevision.CODE_INVALID_PARAMS, "Tool input schema validation 
could not be scheduled within " + MAX_SCHEDULING_MILLIS + " ms");
                        }
-                       var computeRemainingNanos = remaining - 
(System.nanoTime() - startedAtNanos.get());
-                       future.get(Math.max(0, computeRemainingNanos), 
NANOSECONDS);
+                       var start = taskStart.get();
+                       if (CPU_TIME_SUPPORTED && start.cpuNanos() >= 0)
+                               awaitByCpuTime(future, start.threadId(), 
start.cpuNanos(), remaining);
+                       else
+                               future.get(Math.max(0, remaining - 
(System.nanoTime() - start.wallNanos())), NANOSECONDS);  // HTT: wall-clock 
fallback; CI JVMs use the CPU-time path.
                } catch (TimeoutException e) {
                        future.cancel(true);
                        throw validationTimeoutException();
@@ -190,6 +260,45 @@ final class McpSchemaSafety {
                }
        }
 
+       /**
+        * Bounds a running validation by the validating thread's actual CPU 
time.
+        *
+        * <p>
+        * The waiting thread polls at {@link #CPU_POLL_INTERVAL_NANOS} 
intervals: each time the task hasn't finished
+        * yet, it re-samples thread CPU time and keeps waiting as long as the 
CPU consumed since {@code baseCpuNanos}
+        * is within {@code budgetNanos}. Wall-clock elapsed while the thread 
was preempted (or deliberately sleeping)
+        * accrues no CPU, so it never trips the budget; a schema that 
genuinely burns CPU does. Validation is pure
+        * in-memory work with no I/O or blocking, so CPU accrual is a 
sufficient bound and no wall-clock ceiling is
+        * needed here - a task that never returns would necessarily be burning 
CPU and will trip the budget.
+        *
+        * @param future The running validation task.
+        * @param threadId The validating thread's id.
+        * @param baseCpuNanos The validating thread's CPU-time baseline 
captured at task start.
+        * @param budgetNanos The maximum CPU time, in nanoseconds, the 
validation may consume.
+        * @throws TimeoutException If the CPU budget is exceeded while the 
task is still running.
+        */
+       private static void awaitByCpuTime(Future<?> future, long threadId, 
long baseCpuNanos, long budgetNanos)
+                       throws InterruptedException, ExecutionException, 
TimeoutException {
+               while (true) {
+                       try {
+                               future.get(CPU_POLL_INTERVAL_NANOS, 
NANOSECONDS);
+                               return;
+                       } catch (TimeoutException poll) {
+                               var cpuNow = 
THREAD_MX.getThreadCpuTime(threadId);
+                               if (cpuNow >= 0 && cpuNow - baseCpuNanos <= 
budgetNanos)
+                                       continue;  // real CPU work still 
within budget; preemption/scheduling doesn't count against it
+                               if (future.isDone())
+                                       continue;  // finished inside the 
sampling race window; the next get() harvests its result/exception
+                               throw poll;  // CPU work exceeded the DoS 
budget (or CPU timing was lost) while the task is still running
+                       }
+               }
+       }
+
+       /** Whether the compute budget is charged against thread CPU time (vs. 
the wall-clock fallback); for tests. */
+       static boolean cpuTimeBudgetEnabled() {
+               return CPU_TIME_SUPPORTED;
+       }
+
        private static McpException validationTimeoutException() {
                return new McpException(McpRevision.CODE_INVALID_PARAMS, "Tool 
input schema validation exceeded " + MAX_VALIDATION_MILLIS + " ms");
        }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
index 22af41ae8f..fd4855c23f 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
@@ -41,7 +41,7 @@ import org.junit.jupiter.api.*;
 
 /**
  * Coverage for {@link McpSchemaSafety}: no-fetch handling of external {@code 
$ref}s and bounded
- * (depth / node / wall-clock) schema validation (Resolution B2).
+ * (depth / node / CPU-time) schema validation (Resolution B2).
  */
 class McpSchemaSafety_Test {
 
@@ -159,7 +159,7 @@ class McpSchemaSafety_Test {
                assertContains("node count", e.getMessage());
        }
 
-       // -------- bounded wall-clock ---------
+       // -------- bounded compute (thread CPU time, with wall-clock fallback) 
---------
 
        @Test
        void d01_adversarialValidation_terminatesWithinDeadline() {
@@ -190,23 +190,107 @@ class McpSchemaSafety_Test {
                // deliberately delays counting down `started` well past 
MAX_VALIDATION_MILLIS before doing its
                // (instantaneous) "work". If scheduling latency were - the 
regression this guards against - counted
                // against the compute budget, awaitBounded() would throw a 
timeout error despite the task's own
-               // work costing nothing; with the root-cause fix, only compute 
time (measured from `started`) counts.
+               // work costing nothing; with the root-cause fix, only compute 
time (measured from the task's start
+               // snapshot) counts.
                var schedulingDelayMillis = 3 * 
McpSchemaSafety.MAX_VALIDATION_MILLIS;
                assertTrue(schedulingDelayMillis < 
McpSchemaSafety.MAX_SCHEDULING_MILLIS, "test fixture assumption");
 
                var started = new CountDownLatch(1);
-               var startedAtNanos = new AtomicLong();
+               var taskStart = new 
AtomicReference<McpSchemaSafety.TaskStart>();
                var executor = Executors.newSingleThreadExecutor();
                try {
                        var future = executor.submit(() -> {
                                Thread.sleep(schedulingDelayMillis);  // 
simulates thread-pool queueing/scheduling delay
-                               startedAtNanos.set(System.nanoTime());
+                               
taskStart.set(McpSchemaSafety.TaskStart.capture());
                                started.countDown();
                                return null;  // the "validation" work itself 
is instantaneous
                        });
 
                        assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
-                               future, started, startedAtNanos, 
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+                               future, started, taskStart, 
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+               } finally {
+                       executor.shutdownNow();
+               }
+       }
+
+       @SuppressWarnings({
+               "java:S2925" // Thread.sleep here deterministically models a 
validation that elapses wall-clock without consuming CPU, not a wait-and-hope 
delay.
+       })
+       @Test
+       void d03_sleepingValidation_doesNotTripCpuBudget() {
+               // The core of the wall-clock->CPU-time fix: a "validation" 
that lets a lot of wall-clock elapse but
+               // consumes ~no CPU (here, by sleeping *after* its start 
snapshot) must NOT trip the compute budget,
+               // because only real CPU work is the DoS threat. Under the old 
wall-clock measurement this would have
+               // tripped MAX_VALIDATION_MILLIS. Only meaningful when CPU 
timing is active (otherwise awaitBounded
+               // legitimately falls back to wall-clock, under which a sleep 
does count).
+               Assumptions.assumeTrue(McpSchemaSafety.cpuTimeBudgetEnabled(), 
"per-thread CPU timing unavailable on this JVM");
+               var sleepMillis = 4 * McpSchemaSafety.MAX_VALIDATION_MILLIS;  
// far past the budget in wall-clock terms
+               assertTrue(sleepMillis < McpSchemaSafety.MAX_SCHEDULING_MILLIS, 
"test fixture assumption");
+
+               var started = new CountDownLatch(1);
+               var taskStart = new 
AtomicReference<McpSchemaSafety.TaskStart>();
+               var executor = Executors.newSingleThreadExecutor();
+               try {
+                       var future = executor.submit(() -> {
+                               
taskStart.set(McpSchemaSafety.TaskStart.capture());
+                               started.countDown();
+                               Thread.sleep(sleepMillis);  // wall-clock 
elapses well past the budget, but burns ~no CPU
+                               return null;
+                       });
+
+                       assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
+                               future, started, taskStart, 
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+               } finally {
+                       executor.shutdownNow();
+               }
+       }
+
+       @Test
+       void d04_cpuBurningValidation_tripsBudget() {
+               // The complement of d03: a "validation" that actually burns 
CPU past the budget MUST still trip it and
+               // raise the same -32602 error. A generous CPU-burn margin 
(many multiples of the budget) keeps this
+               // robust rather than racing a tight wall-clock bound.
+               var started = new CountDownLatch(1);
+               var taskStart = new 
AtomicReference<McpSchemaSafety.TaskStart>();
+               var executor = Executors.newSingleThreadExecutor();
+               try {
+                       var future = executor.submit(() -> {
+                               
taskStart.set(McpSchemaSafety.TaskStart.capture());
+                               started.countDown();
+                               var sink = 0L;
+                               while (! 
Thread.currentThread().isInterrupted())  // spins until awaitBounded trips the 
budget and cancels us
+                                       for (var i = 1; i < 5_000_000; i++)
+                                               sink += (long) Math.sqrt(i) * i;
+                               return sink;  // returned only so the JIT can't 
elide the loop
+                       });
+
+                       var e = assertThrows(McpException.class, () -> 
McpSchemaSafety.awaitBounded(
+                               future, started, taskStart, 
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
+                       assertEquals(-32602, e.getCode());
+                       assertContains("exceeded " + 
McpSchemaSafety.MAX_VALIDATION_MILLIS + " ms", e.getMessage());
+               } finally {
+                       executor.shutdownNow();
+               }
+       }
+
+       @Test
+       void d05_cpuTimeUnavailable_fallsBackToWallClock() {
+               // When per-thread CPU timing is unavailable (cpuNanos == -1 in 
the start snapshot), awaitBounded must
+               // fall back to the original wall-clock budget so the guard 
still functions on such JVMs. Simulated here
+               // by handing awaitBounded a snapshot with cpuNanos == -1; the 
instantaneous task completes well within
+               // the wall-clock window.
+               var started = new CountDownLatch(1);
+               var taskStart = new 
AtomicReference<McpSchemaSafety.TaskStart>();
+               var executor = Executors.newSingleThreadExecutor();
+               try {
+                       var future = executor.submit(() -> {
+                               taskStart.set(new 
McpSchemaSafety.TaskStart(Thread.currentThread().getId(), System.nanoTime(), 
-1L));
+                               started.countDown();
+                               return null;  // instantaneous work, well 
within the wall-clock window
+                       });
+
+                       assertDoesNotThrow(() -> McpSchemaSafety.awaitBounded(
+                               future, started, taskStart, 
TimeUnit.MILLISECONDS.toNanos(McpSchemaSafety.MAX_VALIDATION_MILLIS)));
                } finally {
                        executor.shutdownNow();
                }
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
index e01c7976b0..fcbaa8bcca 100644
--- 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher.java
@@ -66,9 +66,14 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
        /** Default template-cache flag &mdash; {@code true} (production-safe). 
*/
        public static final boolean DEFAULT_CACHE_TEMPLATES = true;
 
+       /** Default field-exposure flag &mdash; {@code true} (public-field DTOs 
render out of the box). */
+       public static final boolean DEFAULT_EXPOSE_FIELDS = true;
+
        private final String basePath;
        private final String templateSuffix;
        private final boolean cacheTemplates;
+       private final boolean exposeFields;
+       private final ObjectWrapper objectWrapper;
 
        // Lazy bridge-default configuration. Built on first call to 
resolveConfiguration(...) when
        // no Configuration bean is registered in the request's BeanStore. 
Volatile so the
@@ -96,6 +101,8 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                basePath = builder.basePath;
                templateSuffix = builder.templateSuffix;
                cacheTemplates = builder.cacheTemplates;
+               exposeFields = builder.exposeFields;
+               objectWrapper = builder.objectWrapper;
        }
 
        /**
@@ -125,6 +132,25 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                return cacheTemplates;
        }
 
+       /**
+        * Returns the field-exposure flag applied to the bridge-default object 
wrapper.
+        *
+        * @return The field-exposure flag. Ignored once {@link 
#getObjectWrapper()} returns non-{@code null}.
+        */
+       public boolean isExposeFields() {
+               return exposeFields;
+       }
+
+       /**
+        * Returns the user-supplied {@link ObjectWrapper} override, if any.
+        *
+        * @return The object wrapper override, or {@code null} if the bridge 
builds its own default
+        *      (a {@link DefaultObjectWrapper} configured per {@link 
#isExposeFields() isExposeFields()}).
+        */
+       public ObjectWrapper getObjectWrapper() {
+               return objectWrapper;
+       }
+
        /**
         * Appends {@link #getTemplateSuffix()} to {@code name} if not already 
present (idempotent).
         *
@@ -183,9 +209,16 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
         * pins {@code IncompatibleImprovements} to {@link 
Configuration#VERSION_2_3_34} so behavior is
         * stable across consumer upgrades of {@code 
org.freemarker:freemarker}; sets
         * {@code DefaultEncoding} to {@code UTF-8} and {@code OutputFormat} to
-        * {@link HTMLOutputFormat#INSTANCE} so HTML escaping is the natural 
target; and applies
+        * {@link HTMLOutputFormat#INSTANCE} so HTML escaping is the natural 
target; applies
         * {@code TemplateUpdateDelayMilliseconds} per the configured
-        * {@link #isCacheTemplates() cacheTemplates} flag.
+        * {@link #isCacheTemplates() cacheTemplates} flag; and sets an {@code 
ObjectWrapper} &mdash;
+        * either the user-supplied {@link #getObjectWrapper() objectWrapper} 
override, or (by default)
+        * a {@link DefaultObjectWrapper} built with {@code exposeFields} set to
+        * {@link #isExposeFields() isExposeFields()}. Field exposure defaults 
to {@code true} (unlike
+        * FreeMarker's own version-default wrapper, which is getter-only): 
Juneau's own marshalling is
+        * comfortable with public-field beans elsewhere, so a view-model DTO 
written with public fields
+        * and no getters renders its field values here too, instead of 
silently resolving to
+        * {@code null}/missing.
         *
         * <p>
         * Subclasses may override to plug in custom loaders / encodings / 
output formats without
@@ -199,9 +232,23 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                cfg.setDefaultEncoding("UTF-8");
                cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);
                cfg.setTemplateUpdateDelayMilliseconds(cacheTemplates ? 
Long.MAX_VALUE : 0L);
+               cfg.setObjectWrapper(objectWrapper != null ? objectWrapper : 
buildDefaultObjectWrapper());
                return cfg;
        }
 
+       /**
+        * Builds the bridge-default {@link ObjectWrapper} &mdash; a {@link 
DefaultObjectWrapper} with
+        * {@code exposeFields} set per {@link #isExposeFields() 
isExposeFields()}. Only called when no
+        * {@link #getObjectWrapper() objectWrapper} override has been supplied.
+        *
+        * @return A new {@link DefaultObjectWrapper} instance.
+        */
+       private ObjectWrapper buildDefaultObjectWrapper() {
+               var b = new 
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34);
+               b.setExposeFields(exposeFields);
+               return b.build();
+       }
+
        /**
         * Translates a virtual base path (e.g. {@code "/templates/"}) into a 
FreeMarker
         * {@code ClassLoaderTemplateLoader} resource root (e.g. {@code 
"/templates"}).
@@ -314,6 +361,8 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                String basePath = DEFAULT_BASE_PATH;
                String templateSuffix = DEFAULT_TEMPLATE_SUFFIX;
                boolean cacheTemplates = DEFAULT_CACHE_TEMPLATES;
+               boolean exposeFields = DEFAULT_EXPOSE_FIELDS;
+               ObjectWrapper objectWrapper;
 
                /** Constructor &mdash; package access for {@link 
FreemarkerDispatcher#create()}. */
                protected Builder() {}
@@ -354,6 +403,42 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                        return this;
                }
 
+               /**
+                * Sets whether the bridge-default {@code ObjectWrapper} 
exposes public fields (not just
+                * JavaBean getters) to templates.
+                *
+                * <p>
+                * Defaults to {@link 
FreemarkerDispatcher#DEFAULT_EXPOSE_FIELDS true} so simple view-model
+                * DTOs written with public fields and no getters render their 
field values instead of
+                * silently resolving to {@code null}/missing. Has no effect 
once {@link #objectWrapper}
+                * has been called with a non-{@code null} value &mdash; the 
explicit wrapper always wins.
+                *
+                * @param value The field-exposure flag.
+                * @return This object.
+                */
+               public Builder exposeFields(boolean value) {
+                       exposeFields = value;
+                       return this;
+               }
+
+               /**
+                * Sets a fully custom {@code ObjectWrapper} for the 
bridge-default configuration,
+                * overriding {@link #exposeFields(boolean)} entirely.
+                *
+                * <p>
+                * Escape hatch for consumers who need full control (e.g. a 
{@code BeansWrapper} with
+                * custom method/property exposure, or a third-party wrapper) 
without hand-rolling and
+                * registering a whole replacement {@code Configuration} bean.
+                *
+                * @param value The object wrapper. {@code null} reverts to the 
bridge-built
+                *      {@link DefaultObjectWrapper} (per {@link 
#exposeFields(boolean)}).
+                * @return This object.
+                */
+               public Builder objectWrapper(ObjectWrapper value) {
+                       objectWrapper = value;
+                       return this;
+               }
+
                /**
                 * Reads the current base path setting (test/inspection helper).
                 *
@@ -381,6 +466,24 @@ public class FreemarkerDispatcher implements 
RawTemplateDispatcher {
                        return cacheTemplates;
                }
 
+               /**
+                * Reads the current field-exposure setting (test/inspection 
helper).
+                *
+                * @return The field-exposure flag.
+                */
+               public boolean isExposeFields() {
+                       return exposeFields;
+               }
+
+               /**
+                * Reads the current object-wrapper override (test/inspection 
helper).
+                *
+                * @return The object wrapper override, or {@code null} if none 
has been set.
+                */
+               public ObjectWrapper getObjectWrapper() {
+                       return objectWrapper;
+               }
+
                /**
                 * Builds the {@link FreemarkerDispatcher}.
                 *
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
index b4d0de7c16..20291fd129 100644
--- 
a/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin.java
@@ -37,12 +37,46 @@ import freemarker.template.*;
  *             the importer's classpath by asking the configured {@link 
Configuration} for the named
  *             template and rendering it with an empty data model (raw render 
path; callers who want
  *             attributes use {@link FreemarkerView} from a typed handler 
instead).
- *     <li>Picks up {@link FreemarkerViewRenderer} automatically via the 
mixin's
- *             {@link Rest#responseProcessors() @Rest(responseProcessors=...)} 
declaration, so
- *             {@code @RestOp}-method return values of type {@link 
FreemarkerView} render through the
- *             FreeMarker engine without any additional wiring.
+ *     <li>Registers {@link FreemarkerViewRenderer} for the mixin's <b>own</b> 
endpoints (e.g. the
+ *             {@code /freemarker/*} route above) via the mixin's own
+ *             {@link Rest#responseProcessors() @Rest(responseProcessors=...)} 
declaration.
  * </ol>
  *
+ * <h5 class='section'>Auto-wiring the renderer into the host's own 
endpoints:</h5>
+ *
+ * <p>
+ * A bare {@code @Rest(mixins=FreemarkerMixin.class)} is enough &mdash; a host 
whose own {@code @RestOp} method
+ * returns {@link FreemarkerView} renders it through the FreeMarker engine 
with no extra wiring. That works
+ * because {@code FreemarkerMixin} declares
+ * {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)} on its own class,
+ * so the framework folds {@code FreemarkerMixin}'s {@link 
FreemarkerViewRenderer} into the host's own
+ * response-processor chain automatically.
+ *
+ * <p>
+ * This fold is per-mixin, opt-in, and response-processor-scoped: it happens 
only because
+ * {@code FreemarkerMixin} declares the opt-in (a mixin that does not is still 
scoped to its own endpoints, the
+ * standard {@link Rest#mixins() @Rest(mixins=...)} rule), and it folds only 
the renderer &mdash; never
+ * {@code guards}, {@code serializers}, or any other list-shaped attribute.
+ *
+ * <p>
+ * Two explicit wirings remain supported &mdash; useful when you want to be 
explicit, or to fold more than the
+ * renderer:
+ *
+ * <ol class='spaced-list'>
+ *     <li><b>{@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)}</b> via 
the rich {@link Rest#mixinDefs()
+ *             mixinDefs} form &mdash; folds <b>all</b> of {@code 
FreemarkerMixin}'s list-shaped {@code @Rest}
+ *             attributes into the host's own chain, not just the renderer.
+ *     <li><b>List {@link FreemarkerViewRenderer FreemarkerViewRenderer.class} 
explicitly</b> in the host's own
+ *             {@code @Rest(responseProcessors=...)} &mdash; the fully manual 
equivalent.
+ * </ol>
+ *
+ * <p>
+ * The {@link org.apache.juneau.rest.server.processor.ResponseProcessorList} 
partition pass repositions
+ * {@link FreemarkerViewRenderer} (a
+ * {@link org.apache.juneau.rest.server.view.ViewRenderer ViewRenderer}) ahead 
of
+ * {@code SerializedPojoProcessor} in every case, so the {@link 
FreemarkerView} bean is dispatched to the
+ * FreeMarker engine rather than bean-serialized.
+ *
  * <h5 class='figure'>Composition example (microservice):</h5>
  *
  * <p class='bjava'>
@@ -102,16 +136,33 @@ import freemarker.template.*;
  *             {@code ClassLoaderTemplateResolver}-equivalent prefix {@code 
"/templates"}). The default
  *             pins {@code IncompatibleImprovements} to the bridge-tested 
minor version
  *             ({@code Configuration.VERSION_2_3_34}), sets {@code 
DefaultEncoding} to {@code UTF-8},
- *             uses {@code HTMLOutputFormat} so HTML escaping is the natural 
target, and applies
+ *             uses {@code HTMLOutputFormat} so HTML escaping is the natural 
target, applies
  *             {@code TemplateUpdateDelayMilliseconds} per the {@link 
#isCacheTemplates() cacheTemplates}
  *             flag (production-safe by default; users opt into hot-reload via
- *             {@link Builder#cacheTemplates(boolean) cacheTemplates(false)}).
+ *             {@link Builder#cacheTemplates(boolean) cacheTemplates(false)}), 
and sets an
+ *             {@code ObjectWrapper} with {@code exposeFields=true} (see 
below) unless overridden.
  * </ul>
  *
  * <p>
  * When no FreeMarker engine is on the classpath, the renderer surfaces
  * {@link FreemarkerViewRenderer#NO_ENGINE_DIAGNOSTIC} naming the missing 
dependency.
  *
+ * <h5 class='section'>Public-field DTOs (getter-only default trap):</h5>
+ *
+ * <p>
+ * FreeMarker's own version-default {@code ObjectWrapper} exposes only 
JavaBean getters &mdash; a
+ * view-model bean written as a simple DTO with public fields and no getters 
resolves every
+ * {@code ${bean.field}} reference to {@code null}/missing, <b>silently</b> (a 
template using
+ * {@code !'default'} fallbacks renders the default with no error at all; a 
bare {@code ${bean.field}}
+ * with no fallback throws at render time). This bridge avoids that trap by 
default: the bridge-built
+ * {@link Configuration} sets a {@link DefaultObjectWrapper} with {@code 
exposeFields=true}
+ * ({@link #DEFAULT_EXPOSE_FIELDS}), so public-field DTOs render their field 
values out of the
+ * box &mdash; matching how Juneau's own marshalling is comfortable with 
public-field beans elsewhere.
+ * Use {@link Builder#exposeFields(boolean) exposeFields(false)} to restore 
FreeMarker's getter-only
+ * behavior, or {@link Builder#objectWrapper(ObjectWrapper) 
objectWrapper(...)} for full control
+ * (e.g. a {@code BeansWrapper} with custom exposure rules). Both knobs only 
affect the
+ * bridge-default {@link Configuration}; a user-supplied {@code @Bean 
Configuration} is used as-is.
+ *
  * <h5 class='section'>Template suffix:</h5>
  *
  * <p>
@@ -155,7 +206,8 @@ import freemarker.template.*;
  */
 // @formatter:off
 @Rest(
-       responseProcessors={FreemarkerViewRenderer.class}
+       responseProcessors={FreemarkerViewRenderer.class},
+       mergeResponseProcessorsIntoHost=true
 )
 public class FreemarkerMixin {
 
@@ -168,6 +220,9 @@ public class FreemarkerMixin {
        /** Default template-cache flag &mdash; {@code true} (production-safe). 
*/
        public static final boolean DEFAULT_CACHE_TEMPLATES = 
FreemarkerDispatcher.DEFAULT_CACHE_TEMPLATES;
 
+       /** Default field-exposure flag &mdash; {@code true} (public-field DTOs 
render out of the box). */
+       public static final boolean DEFAULT_EXPOSE_FIELDS = 
FreemarkerDispatcher.DEFAULT_EXPOSE_FIELDS;
+
        private final FreemarkerDispatcher worker;
 
        /**
@@ -229,6 +284,24 @@ public class FreemarkerMixin {
                return worker.isCacheTemplates();
        }
 
+       /**
+        * Returns the field-exposure flag applied to the bridge-default object 
wrapper.
+        *
+        * @return The field-exposure flag. Ignored once {@link 
#getObjectWrapper()} returns non-{@code null}.
+        */
+       public boolean isExposeFields() {
+               return worker.isExposeFields();
+       }
+
+       /**
+        * Returns the user-supplied {@link ObjectWrapper} override, if any.
+        *
+        * @return The object wrapper override, or {@code null} if the bridge 
builds its own default.
+        */
+       public ObjectWrapper getObjectWrapper() {
+               return worker.getObjectWrapper();
+       }
+
        /**
         * Appends {@link #getTemplateSuffix()} to {@code name} if not already 
present (idempotent).
         *
@@ -357,6 +430,43 @@ public class FreemarkerMixin {
                        return this;
                }
 
+               /**
+                * Sets whether the bridge-default {@code ObjectWrapper} 
exposes public fields (not just
+                * JavaBean getters) to templates.
+                *
+                * <p>
+                * Defaults to {@link FreemarkerMixin#DEFAULT_EXPOSE_FIELDS 
true} so simple view-model
+                * DTOs written with public fields and no getters render their 
field values instead of
+                * silently resolving to {@code null}/missing (see the 
class-level
+                * "Public-field DTOs" section). Has no effect once {@link 
#objectWrapper(ObjectWrapper)}
+                * has been called with a non-{@code null} value &mdash; the 
explicit wrapper always wins.
+                *
+                * @param value The field-exposure flag.
+                * @return This object.
+                */
+               public Builder exposeFields(boolean value) {
+                       worker.exposeFields(value);
+                       return this;
+               }
+
+               /**
+                * Sets a fully custom {@code ObjectWrapper} for the 
bridge-default configuration,
+                * overriding {@link #exposeFields(boolean)} entirely.
+                *
+                * <p>
+                * Escape hatch for consumers who need full control (e.g. a 
{@code BeansWrapper} with
+                * custom method/property exposure, or a third-party wrapper) 
without hand-rolling and
+                * registering a whole replacement {@code Configuration} bean.
+                *
+                * @param value The object wrapper. {@code null} reverts to the 
bridge-built
+                *      {@link DefaultObjectWrapper} (per {@link 
#exposeFields(boolean)}).
+                * @return This object.
+                */
+               public Builder objectWrapper(ObjectWrapper value) {
+                       worker.objectWrapper(value);
+                       return this;
+               }
+
                /**
                 * Reads the current base path setting (test/inspection helper).
                 *
@@ -384,6 +494,24 @@ public class FreemarkerMixin {
                        return worker.isCacheTemplates();
                }
 
+               /**
+                * Reads the current field-exposure setting (test/inspection 
helper).
+                *
+                * @return The field-exposure flag.
+                */
+               public boolean isExposeFields() {
+                       return worker.isExposeFields();
+               }
+
+               /**
+                * Reads the current object-wrapper override (test/inspection 
helper).
+                *
+                * @return The object wrapper override, or {@code null} if none 
has been set.
+                */
+               public ObjectWrapper getObjectWrapper() {
+                       return worker.getObjectWrapper();
+               }
+
                /**
                 * Builds the {@link FreemarkerMixin}.
                 *
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
index 11aeafab91..a80458fa3a 100644
--- 
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
 import java.io.*;
+import java.util.*;
 
 import org.apache.juneau.*;
 import org.apache.juneau.commons.inject.*;
@@ -29,6 +30,7 @@ import org.junit.jupiter.api.*;
 
 import freemarker.cache.*;
 import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapperBuilder;
 
 /**
  * Unit tests for {@link FreemarkerDispatcher#render(String, RestRequest, 
RestResponse) render(...)} and
@@ -178,4 +180,55 @@ class FreemarkerDispatcher_Test extends TestBase {
                var cfg = dispatcher.buildDefaultConfiguration();
                assertEquals(Long.MAX_VALUE, 
cfg.getTemplateUpdateDelayMilliseconds());
        }
+
+       /* 
----------------------------------------------------------------------------------------
 *
+        * Section D: buildDefaultConfiguration object-wrapper / exposeFields 
behavior
+        * 
----------------------------------------------------------------------------------------
 */
+
+       /** View-model DTO with a public field and no getter — the shape that 
bit the dogfooded consumer. */
+       public static class D_FieldOnlyBean {
+               public String name = "Alice";
+       }
+
+       private static String renderToString(Configuration cfg, String 
template, Object bean) throws Exception {
+               var loader = new StringTemplateLoader();
+               loader.putTemplate("t", template);
+               cfg.setTemplateLoader(loader);
+               var sw = new StringWriter();
+               cfg.getTemplate("t").process(Map.of("bean", bean), sw);
+               return sw.toString();
+       }
+
+       @Test void 
d01_buildDefaultConfiguration_exposeFieldsDefaultTrue_rendersPublicFieldValue() 
throws Exception {
+               var dispatcher = FreemarkerDispatcher.create().build();
+               var cfg = dispatcher.buildDefaultConfiguration();
+
+               assertEquals("Alice", renderToString(cfg, "${bean.name}", new 
D_FieldOnlyBean()));
+       }
+
+       @Test void d02_exposeFieldsFalse_publicFieldIsInvisibleToTemplates() 
throws Exception {
+               var dispatcher = 
FreemarkerDispatcher.create().exposeFields(false).build();
+               var cfg = dispatcher.buildDefaultConfiguration();
+
+               // With exposeFields=false (FreeMarker's own version-default 
behavior), the public field
+               // isn't visible as a bean property, so a defaulted reference 
falls through silently.
+               assertEquals("MISSING", renderToString(cfg, 
"${bean.name!'MISSING'}", new D_FieldOnlyBean()));
+       }
+
+       @Test void d03_objectWrapperOverride_takesPrecedenceOverExposeFields() {
+               var b = new 
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34);
+               b.setExposeFields(false);
+               var custom = b.build();
+               var dispatcher = 
FreemarkerDispatcher.create().exposeFields(true).objectWrapper(custom).build();
+
+               var cfg = dispatcher.buildDefaultConfiguration();
+
+               assertSame(custom, cfg.getObjectWrapper());
+       }
+
+       @Test void d04_exposeFieldsAndObjectWrapperDefaultToTrueAndNull() {
+               var dispatcher = FreemarkerDispatcher.create().build();
+               assertTrue(dispatcher.isExposeFields());
+               assertNull(dispatcher.getObjectWrapper());
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
 
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
index 13bc53438c..b0a41f350c 100644
--- 
a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
+++ 
b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerMixin_Builder_Test.java
@@ -138,6 +138,36 @@ class FreemarkerMixin_Builder_Test extends TestBase {
                assertNotNull(c1.getTemplateLoader());
        }
 
+       @Test void a14_exposeFieldsDefaultsTrue() {
+               var r = FreemarkerMixin.create().build();
+               assertTrue(r.isExposeFields());
+       }
+
+       @Test void a15_exposeFieldsSetterRoundTrips() {
+               var r = FreemarkerMixin.create().exposeFields(false).build();
+               assertFalse(r.isExposeFields());
+       }
+
+       @Test void a16_objectWrapperDefaultsNull() {
+               var r = FreemarkerMixin.create().build();
+               assertNull(r.getObjectWrapper());
+       }
+
+       @Test void a17_objectWrapperSetterRoundTrips() {
+               var wrapper = new 
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34).build();
+               var r = FreemarkerMixin.create().objectWrapper(wrapper).build();
+               assertSame(wrapper, r.getObjectWrapper());
+       }
+
+       @Test void 
a18_builderReadersReflectExposeFieldsAndObjectWrapperMutations() {
+               var wrapper = new 
DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34).build();
+               var b = FreemarkerMixin.create()
+                       .exposeFields(false)
+                       .objectWrapper(wrapper);
+               assertFalse(b.isExposeFields());
+               assertSame(wrapper, b.getObjectWrapper());
+       }
+
        /* 
----------------------------------------------------------------------------------------
 *
         * Section B: applyTemplateSuffix helper (idempotent appender)
         * 
----------------------------------------------------------------------------------------
 */
diff --git 
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
 
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
index 4ef847187a..79ad89e3d6 100644
--- 
a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
+++ 
b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspMixin.java
@@ -42,35 +42,41 @@ import org.apache.juneau.rest.server.view.*;
  * <h5 class='section'>Auto-wiring the renderer into the host's own 
endpoints:</h5>
  *
  * <p>
- * By default a mixin's {@code responseProcessors} apply only to the mixin's 
own endpoints &mdash; a host's own
- * {@code @RestOp} methods see only the host's chain (the {@link Rest#mixins() 
@Rest(mixins=...)} rule "host's
- * chain runs first, then the mixin's appended; host endpoints see only the 
host's chain").  So with a bare
- * {@code @Rest(mixins=JspMixin.class)}, a host whose own {@code @RestOp} 
returns {@link JspView} would have the
- * framework's default {@code SerializedPojoProcessor} bean-serialize it 
instead of dispatching it to the JSP
- * engine.  Two ways to make the host's own {@code JspView} returns reach 
{@link JspViewRenderer}:
+ * A bare {@code @Rest(mixins=JspMixin.class)} is enough &mdash; a host whose 
own {@code @RestOp} method returns
+ * {@link JspView} renders it through the JSP engine with no extra wiring.  
That works because {@code JspMixin}
+ * declares {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)} on its own
+ * class, so the framework folds {@code JspMixin}'s {@link JspViewRenderer} 
into the host's own response-processor
+ * chain automatically.
+ *
+ * <p>
+ * This fold is per-mixin, opt-in, and response-processor-scoped: it happens 
only because {@code JspMixin}
+ * declares the opt-in (a mixin that does not is still scoped to its own 
endpoints, the standard
+ * {@link Rest#mixins() @Rest(mixins=...)} rule), and it folds only the 
renderer &mdash; never {@code guards},
+ * {@code serializers}, or any other list-shaped attribute.
+ *
+ * <p>
+ * Two explicit wirings remain supported &mdash; useful when you want to be 
explicit, or to fold more than the
+ * renderer:
  *
  * <ol class='spaced-list'>
- *     <li><b>Opt in via {@link Mixin#mergeIntoHost() 
@Mixin(mergeIntoHost=true)}</b> (recommended) &mdash; the
- *             host declares the mixin through the rich {@link 
Rest#mixinDefs() mixinDefs} form with
- *             {@code mergeIntoHost=true}, which folds {@code JspMixin}'s 
{@code @Rest(responseProcessors=...)} (and any
- *             other list-shaped attributes) into the host's own chain.  No 
need to repeat
- *             {@link JspViewRenderer JspViewRenderer.class} on the host.
+ *     <li><b>{@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)}</b> via 
the rich {@link Rest#mixinDefs()
+ *             mixinDefs} form &mdash; folds <b>all</b> of {@code JspMixin}'s 
list-shaped {@code @Rest} attributes into
+ *             the host's own chain, not just the renderer.
  *     <li><b>List {@link JspViewRenderer JspViewRenderer.class} 
explicitly</b> in the host's own
- *             {@code @Rest(responseProcessors=...)} &mdash; the manual 
equivalent, useful when the host does not
- *             declare the mixin via {@code mixinDefs}.
+ *             {@code @Rest(responseProcessors=...)} &mdash; the fully manual 
equivalent.
  * </ol>
  *
  * <p>
  * The {@link org.apache.juneau.rest.server.processor.ResponseProcessorList} 
partition pass repositions
  * {@link JspViewRenderer} (a
  * {@link org.apache.juneau.rest.server.view.ViewRenderer ViewRenderer}) ahead 
of
- * {@code SerializedPojoProcessor} in either case, so the {@link JspView} bean 
is dispatched to the JSP engine
+ * {@code SerializedPojoProcessor} in every case, so the {@link JspView} bean 
is dispatched to the JSP engine
  * rather than bean-serialized.
  *
  * <h5 class='figure'>Composition example (microservice):</h5>
  *
  * <p class='bjava'>
- *     <ja>@Rest</ja>(path=<js>"/app"</js>, 
mixinDefs=<ja>@Mixin</ja>(type=JspMixin.<jk>class</jk>, 
mergeIntoHost=<jk>true</jk>))
+ *     <ja>@Rest</ja>(path=<js>"/app"</js>, mixins=JspMixin.<jk>class</jk>)
  *     <jk>public class</jk> AppResource <jk>extends</jk> RestServlet {
  *
  *             <ja>@Bean</ja> JspMixin jsp() {
@@ -162,7 +168,8 @@ import org.apache.juneau.rest.server.view.*;
  */
 // @formatter:off
 @Rest(
-       responseProcessors={JspViewRenderer.class}
+       responseProcessors={JspViewRenderer.class},
+       mergeResponseProcessorsIntoHost=true
 )
 public class JspMixin {
 
diff --git 
a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
 
b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
index 768857a114..678f755422 100644
--- 
a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
+++ 
b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
@@ -40,8 +40,10 @@ import org.junit.jupiter.api.*;
  *
  * <p>
  * Fixture {@code A} declares {@code 
@Rest(responseProcessors=JspViewRenderer.class)} directly
- * (the pattern proven to work by {@code JspView_TypedHandler_Test} in {@code 
juneau-integration-tests}),
- * rather than relying on {@code @Rest(mixins=JspMixin.class)} alone — see 
{@code z01} below for why.
+ * (the pattern proven to work by {@code JspView_TypedHandler_Test} in {@code 
juneau-integration-tests}).
+ * A bare {@code @Rest(mixins=JspMixin.class)} now reaches the same code path 
too, because {@code JspMixin}
+ * declares {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)} on its
+ * own class, folding {@link JspViewRenderer} into the host's own chain — see 
{@code z01} below.
  *
  * @since 10.0.0
  */
@@ -71,19 +73,13 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
 
        private static final MockRestClient c = 
MockRestClient.buildLax(A.class);
 
-       // A BARE @Rest(mixins=JspMixin.class) does NOT route a HOST-defined 
@RestGet method's
-       // JspView return value through JspViewRenderer -- this is the default, 
deliberately-isolated behavior.
-       // RestContext#getRestAnnotationsForProperty resolves a HOST context's 
own responseProcessors chain from the
-       // host's OWN @Rest annotation chain (ancestor classes); a mixin 
class's @Rest(responseProcessors=...) is a
-       // property of the MIXIN's own sub-context (consulted only for ops 
declared directly on the mixin class
-       // itself, e.g. JspMixin#render), and is NOT folded into the host's 
list unless the host opts in. See
-       // Rest#mixins() javadoc ("host's chain runs first, then the mixin's 
appended. Host endpoints see only the
-       // host's chain") and MixinInheritance_ResponseProcessors_Test#a02 in 
juneau-integration-tests, which pins
-       // this default isolation. To have a host's own JspView returns reach 
JspViewRenderer, the host opts in via
-       // @Mixin(mergeIntoHost=true) (see MergeIntoHost fixture + z02 below), 
which folds the mixin's list-shaped
-       // @Rest attributes (including responseProcessors) into the host's own 
chain; the manual equivalent is
-       // listing JspViewRenderer.class directly in the host's own 
@Rest(responseProcessors=...) -- exactly like
-       // this class's own fixture A above.
+       // A bare @Rest(mixins=JspMixin.class) DOES route a HOST-defined 
@RestGet method's JspView return value
+       // through JspViewRenderer, because JspMixin declares 
@Rest(mergeResponseProcessorsIntoHost=true) on its own
+       // class. That opt-in folds JspMixin's own 
@Rest(responseProcessors=JspViewRenderer.class) into the host's
+       // own response-processor chain (response-processor-scoped only), so a 
host op returning a JspView reaches
+       // the renderer with no extra wiring. See z01 below, JspMixin's class 
javadoc, and
+       // MixinResponseProcessorFold_Test (rest-server) / 
MixinInheritance_ResponseProcessors_Test
+       // (juneau-integration-tests), which pin the fold at the RestContext 
and mock-client levels respectively.
        @Rest(mixins=JspMixin.class)
        public static class MixinOnly extends BasicRestServlet {
                private static final long serialVersionUID = 1L;
@@ -95,6 +91,29 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
 
        private static final MockRestClient cMixinOnly = 
MockRestClient.buildLax(MixinOnly.class);
 
+       // Non-silent regression guard: a mixin that registers JspViewRenderer 
but does NOT declare the
+       // mergeResponseProcessorsIntoHost opt-in stays scoped to its own 
endpoints -- its responseProcessors are
+       // NOT folded into the host's chain, so a host op returning a JspView 
falls back to bean-serialization.
+       // This is the deliberately-isolated default that JspMixin overrides by 
opting in (see z03 vs z01).
+       @Rest(responseProcessors=JspViewRenderer.class)
+       public static class NoFoldMixin {
+               @RestGet(path="/mixin-only-endpoint")
+               public String noop() {
+                       return "noop";
+               }
+       }
+
+       @Rest(mixins=NoFoldMixin.class)
+       public static class HostWithNoFoldMixin extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @RestGet(path="/view")
+               public View view() {
+                       return JspView.of("hello.jsp");
+               }
+       }
+
+       private static final MockRestClient cNoFoldMixin = 
MockRestClient.buildLax(HostWithNoFoldMixin.class);
+
        // Opt-in host: adopts JspMixin via the rich mixinDefs form with 
mergeIntoHost=true, so the mixin's
        // @Rest(responseProcessors=JspViewRenderer.class) folds into THIS 
host's own chain.
        @Rest(mixinDefs=@Mixin(type=JspMixin.class, mergeIntoHost=true))
@@ -108,10 +127,30 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
 
        private static final MockRestClient cMergeIntoHost = 
MockRestClient.buildLax(MergeIntoHost.class);
 
-       @Test void z01_mixinAlone_doesNotRouteHostViewReturnThroughRenderer() 
throws Exception {
-               // Bean-serialized fallback (SerializedPojoProcessor won), NOT 
JSP-dispatched -- the body contains the
-               // JspView bean's own "templateName" field, which 
JspViewRenderer's actual dispatch path never produces.
-               var res = 
cMixinOnly.get("/view").accept("application/json").run();
+       @Test void z01_mixinAlone_routesHostViewReturnThroughRenderer() throws 
Exception {
+               // JspMixin declares 
@Rest(mergeResponseProcessorsIntoHost=true), so a bare 
@Rest(mixins=JspMixin.class)
+               // folds JspViewRenderer into the host's own chain: a host 
@RestGet returning a JspView is CLAIMED by the
+               // renderer (not bean-serialized). With a well-behaved 
dispatcher that commits the response, the render
+               // succeeds end-to-end (200) -- deterministic proof the fold 
routes AND renders, not merely that it was
+               // intercepted. The companion null-dispatcher assertion below 
pins that it reached the renderer rather than
+               // the default SerializedPojoProcessor (which would have 
produced a 200 "templateName" body, as in z03).
+               var okCtx = fakeServletContext(new FakeDispatcher(() -> { /* 
well-behaved engine committed the response */ }));
+               
cMixinOnly.get("/view").servletContext(okCtx).run().assertStatus(200);
+
+               var noEngineCtx = fakeServletContext(null);
+               var res = 
cMixinOnly.get("/view").servletContext(noEngineCtx).run();
+               res.assertStatus(500);
+               res.assertContent().asString().isContains("Could not resolve 
RequestDispatcher");
+               res.assertContent().asString().isContains("No JSP engine is 
available on the classpath");
+       }
+
+       @Test void 
z03_nonOptedInMixin_doesNotRouteHostViewReturnThroughRenderer() throws 
Exception {
+               // Regression guard for the non-silent contract: NoFoldMixin 
registers JspViewRenderer but does NOT opt in
+               // via mergeResponseProcessorsIntoHost, so the renderer stays 
scoped to the mixin's own endpoints and is
+               // NOT folded into the host's chain. The host's JspView return 
therefore falls back to bean-serialization
+               // (SerializedPojoProcessor won) -- a 200 whose body contains 
the JspView bean's own "templateName" field,
+               // which JspViewRenderer's dispatch path never produces. 
Contrast with z01 (opted-in JspMixin routes).
+               var res = 
cNoFoldMixin.get("/view").accept("application/json").run();
                res.assertStatus(200);
                res.assertContent().asString().isContains("templateName");
        }
@@ -121,7 +160,7 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
                // into the HOST's own chain, so a host @RestGet returning a 
JspView is now CLAIMED by JspViewRenderer
                // (not bean-serialized). With a ServletContext that resolves 
no dispatcher, the renderer surfaces its
                // NO_ENGINE_DIAGNOSTIC 500 -- deterministic proof the JspView 
reached JspViewRenderer rather than the
-               // default SerializedPojoProcessor (which would have produced a 
200 "templateName" body as in z01).
+               // default SerializedPojoProcessor (which would have produced a 
200 "templateName" body as in z03).
                var ctx = fakeServletContext(null);
                var res = cMergeIntoHost.get("/view").servletContext(ctx).run();
                res.assertStatus(500);
diff --git 
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
 
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
index 095810dc04..c46c5d8fdd 100644
--- 
a/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
+++ 
b/juneau-rest/juneau-rest-server-view-mustache/src/main/java/org/apache/juneau/rest/server/view/mustache/MustacheMixin.java
@@ -37,10 +37,12 @@ import com.github.mustachejava.*;
  *             importer's classpath by asking the configured {@link 
MustacheFactory} to compile and
  *             render them with no scope (raw render path; callers who want 
attributes use
  *             {@link MustacheView} from a typed handler instead).
- *     <li>Picks up {@link MustacheViewRenderer} automatically via the mixin's
- *             {@link Rest#responseProcessors() @Rest(responseProcessors=...)} 
declaration, so
- *             {@code @RestOp}-method return values of type {@link 
MustacheView} render through the
- *             Mustache engine without any additional wiring.
+ *     <li>Routes the host's own {@code @RestOp}-method return values of type 
{@link MustacheView} through the
+ *             Mustache engine automatically. {@code MustacheMixin} declares
+ *             {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)}, so a plain
+ *             {@code @Rest(mixins=MustacheMixin.class)} folds its {@link 
MustacheViewRenderer} into the host's own
+ *             response-processor chain &mdash; no {@code mergeIntoHost} or 
explicit {@code responseProcessors=} on the
+ *             host needed.
  * </ol>
  *
  * <h5 class='figure'>Composition example (microservice):</h5>
@@ -150,7 +152,8 @@ import com.github.mustachejava.*;
  */
 // @formatter:off
 @Rest(
-       responseProcessors={MustacheViewRenderer.class}
+       responseProcessors={MustacheViewRenderer.class},
+       mergeResponseProcessorsIntoHost=true
 )
 public class MustacheMixin {
 
diff --git 
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
 
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
index 42d9364f83..c7d53a4d2b 100644
--- 
a/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
+++ 
b/juneau-rest/juneau-rest-server-view-thymeleaf/src/main/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafMixin.java
@@ -37,10 +37,12 @@ import org.thymeleaf.templatemode.*;
  *             the importer's classpath by asking the configured
  *             {@link org.thymeleaf.TemplateEngine TemplateEngine} to render 
them with the current
  *             request's locale and attributes available to the template.
- *     <li>Picks up {@link ThymeleafViewRenderer} automatically via the mixin's
- *             {@link Rest#responseProcessors() @Rest(responseProcessors=...)} 
declaration, so
- *             {@code @RestOp}-method return values of type {@link 
ThymeleafView} render through the
- *             Thymeleaf engine without any additional wiring.
+ *     <li>Routes the host's own {@code @RestOp}-method return values of type 
{@link ThymeleafView} through the
+ *             Thymeleaf engine automatically. {@code ThymeleafMixin} declares
+ *             {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)}, so a plain
+ *             {@code @Rest(mixins=ThymeleafMixin.class)} folds its {@link 
ThymeleafViewRenderer} into the host's own
+ *             response-processor chain &mdash; no {@code mergeIntoHost} or 
explicit {@code responseProcessors=} on the
+ *             host needed.
  * </ol>
  *
  * <h5 class='figure'>Composition example (microservice):</h5>
@@ -134,7 +136,8 @@ import org.thymeleaf.templatemode.*;
  */
 // @formatter:off
 @Rest(
-       responseProcessors={ThymeleafViewRenderer.class}
+       responseProcessors={ThymeleafViewRenderer.class},
+       mergeResponseProcessorsIntoHost=true
 )
 @SuppressWarnings({
        "java:S1192" // Duplicate string literals are Thymeleaf MIME type 
strings and template attribute keys; intentional
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
index a0c8de9f61..16fb24b791 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Rest.java
@@ -363,6 +363,37 @@ public @interface Rest {
         */
        Mixin[] mixinDefs() default {};
 
+       /**
+        * Mixin-declared opt-in: fold this class's own {@link 
#responseProcessors() responseProcessors} into the
+        * <b>host</b> resource's chain whenever this class is composed as a 
mixin via a plain
+        * {@link #mixins() @Rest(mixins=...)} reference.
+        *
+        * <p>
+        * By default ({@code false}) a mixin's {@code responseProcessors} 
apply only to the mixin's own endpoints
+        * &mdash; a host's own {@code @RestOp} methods see only the host's 
chain (the standard {@link #mixins() mixin}
+        * inheritance rule "host's chain runs first, then the mixin's 
appended; host endpoints see only the host's
+        * chain").  Setting this to {@code true} <b>on the mixin class 
itself</b> makes a plain
+        * {@code @Rest(mixins=ThisClass.class)} reference additionally append 
this class's own
+        * {@code responseProcessors} to the end of the host's own chain, so 
the host's own endpoints pick them up too.
+        *
+        * <p>
+        * This is the mixin-declared, response-processor-scoped counterpart to
+        * {@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)} (which is 
host-declared and folds <i>all</i>
+        * list-shaped attributes).  Unlike {@code mergeIntoHost}, this 
directive lives with the mixin and folds
+        * <b>only</b> {@code responseProcessors} &mdash; never {@code guards}, 
{@code serializers}, or any other
+        * list-shaped attribute.  It is intended for view-renderer mixins 
(e.g. {@code FreemarkerMixin}) so that
+        * declaring the mixin is enough to route the host's own {@code View} 
returns through the renderer.
+        *
+        * <p>
+        * Strictly opt-in and non-silent: it has no effect for a mixin that 
leaves it {@code false}, so existing
+        * mixins keep today's scoping exactly.  Same-class de-duplication 
applies, so a response processor the host
+        * already declares is not added twice.
+        *
+        * @return The annotation value.
+        * @since 10.0.0
+        */
+       boolean mergeResponseProcessorsIntoHost() default false;
+
        /**
         * Client version header.
         *
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
index b654d6f7c1..1be09c9ca3 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestAnnotation.java
@@ -80,6 +80,7 @@ public class RestAnnotation {
                private Child[] childrenDefs = {};
                private Class<?>[] mixins = {};
                private Mixin[] mixinDefs = {};
+               private boolean mergeResponseProcessorsIntoHost;
                private Class<?>[] parsers = {};
                private Swagger swagger = SwaggerAnnotation.DEFAULT;
                private String disableContentParam = "";
@@ -250,6 +251,17 @@ public class RestAnnotation {
                        return this;
                }
 
+               /**
+                * Sets the {@link Rest#mergeResponseProcessorsIntoHost()} 
property on this annotation.
+                *
+                * @param value The new value for this property.
+                * @return This object.
+                */
+               public Builder mergeResponseProcessorsIntoHost(boolean value) {
+                       mergeResponseProcessorsIntoHost = value;
+                       return this;
+               }
+
                /**
                 * Sets the {@link Rest#clientVersionHeader()} property on this 
annotation.
                 *
@@ -821,6 +833,7 @@ public class RestAnnotation {
                private final Child[] childrenDefs;
                private final Class<?>[] mixins;
                private final Mixin[] mixinDefs;
+               private final boolean mergeResponseProcessorsIntoHost;
                private final Class<?>[] parsers;
                private final Swagger swagger;
                private final String disableContentParam;
@@ -878,6 +891,7 @@ public class RestAnnotation {
                        childrenDefs = cp(b.childrenDefs);
                        mixins = cp(b.mixins);
                        mixinDefs = cp(b.mixinDefs);
+                       mergeResponseProcessorsIntoHost = 
b.mergeResponseProcessorsIntoHost;
                        clientVersionHeader = b.clientVersionHeader;
                        config = b.config;
                        eagerInit = b.eagerInit;
@@ -976,6 +990,11 @@ public class RestAnnotation {
                        return mixinDefs;
                }
 
+               @Override /* Overridden from Rest */
+               public boolean mergeResponseProcessorsIntoHost() {
+                       return mergeResponseProcessorsIntoHost;
+               }
+
                @Override /* Overridden from Rest */
                public String clientVersionHeader() {
                        return clientVersionHeader;
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
index a3a74d4d52..9a99f51e7c 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
@@ -3558,10 +3558,19 @@ public class RestContext extends Context {
                // Opt-in @Mixin(mergeIntoHost=true) directive: for a host 
context and a list-shaped property, append the
                // adopted mixin(s)' own @Rest contributions AFTER the host's 
chain (parent-to-child), so the host's own
                // endpoints pick up the mixin's list-shaped attributes.  
Default behavior (no opted-in mixin) is unchanged.
-               if (! isMixinContextField() && 
MERGEABLE_LIST_PROPERTIES.contains(name)) {
-                       var merged = mergeIntoHostMixinAnnotations.get();
-                       if (! merged.isEmpty())
-                               return Stream.concat(hostStream, 
merged.stream());
+               if (! isMixinContextField()) {
+                       if (MERGEABLE_LIST_PROPERTIES.contains(name)) {
+                               var merged = 
mergeIntoHostMixinAnnotations.get();
+                               if (! merged.isEmpty())
+                                       hostStream = Stream.concat(hostStream, 
merged.stream());
+                       }
+                       // Mixin-declared 
@Rest(mergeResponseProcessorsIntoHost=true) opt-in: fold the opted-in mixin's 
OWN response
+                       // processors into the host's chain — 
response-processor-scoped only (never other list-shaped attributes).
+                       if (PROPERTY_responseProcessors.equals(name)) {
+                               var mergedRp = 
mergeResponseProcessorsIntoHostMixinAnnotations.get();
+                               if (! mergedRp.isEmpty())
+                                       hostStream = Stream.concat(hostStream, 
mergedRp.stream());
+                       }
                }
                return hostStream;
        }
@@ -3592,6 +3601,40 @@ public class RestContext extends Context {
                return u(out);
        });
 
+       /**
+        * The OWN {@code @Rest} annotation chains (parent-to-child, framework 
{@code DefaultConfig} entries excluded) of any
+        * mixin that opts into folding its response processors into the host 
via
+        * {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)} on its own class,
+        * appended by {@link #getRestAnnotationsForProperty(String)} for the 
{@code responseProcessors} property only so the
+        * mixin's {@code responseProcessors} fold into the host's own chain 
under a plain {@link Rest#mixins() mixins=}
+        * reference.
+        *
+        * <p>
+        * This is the mixin-declared, response-processor-scoped counterpart to 
the host-declared
+        * {@link Mixin#mergeIntoHost() @Mixin(mergeIntoHost=true)} directive 
handled by
+        * {@link #mergeIntoHostMixinAnnotations}: it folds <b>only</b> {@code 
responseProcessors}, never other list-shaped
+        * attributes.  Empty for mixin sub-contexts and for hosts with no 
opted-in mixins &mdash; keeping the default (drop)
+        * behavior for every mixin that does not declare the opt-in.  Built 
once at memoizer-init time (zero per-request
+        * cost).
+        */
+       private final Memoizer<List<AnnotationInfo<Rest>>> 
mergeResponseProcessorsIntoHostMixinAnnotations = memoizer(() -> {
+               if (isMixinContextField())
+                       return List.of();
+               var out = new ArrayList<AnnotationInfo<Rest>>();
+               for (var rm : getResolvedMixins()) {
+                       if (rm.type() == resourceClass())
+                               continue;
+                       // Fold in the mixin class's OWN @Rest chain 
(parent-to-child), excluding the framework DefaultConfig
+                       // entries, but only when the mixin's own @Rest 
declares the mergeResponseProcessorsIntoHost opt-in.
+                       var mixinRest = 
rstream(getAnnotationProvider().find(Rest.class, ClassInfo.of(rm.type())))
+                               .filter(ai -> ! (ai.getAnnotatable() instanceof 
ClassInfo ci && DefaultConfig.class.equals(ci.inner())))
+                               .toList();
+                       if (mixinRest.stream().anyMatch(ai -> 
ai.inner().mergeResponseProcessorsIntoHost()))
+                               out.addAll(mixinRest);
+               }
+               return u(out);
+       });
+
        /**
         * For a {@linkplain #isMixinContext() mixin sub-context}, returns the 
<b>host</b> resource class's
         * class-level annotation infos (host class chain, in parent-to-child 
order) so a mixin operation can
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
new file mode 100644
index 0000000000..700daf70fe
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MixinResponseProcessorFold_Test.java
@@ -0,0 +1,139 @@
+/*
+ * 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.server;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.server.processor.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the mixin-declared {@link Rest#mergeResponseProcessorsIntoHost() 
@Rest(mergeResponseProcessorsIntoHost=true)}
+ * opt-in: a mixin that declares it on its own class has its {@code 
responseProcessors} folded into the host's own chain
+ * under a plain {@link Rest#mixins() @Rest(mixins=...)} reference, while a 
mixin that does NOT declare it keeps today's
+ * isolated scoping (the non-silent contract).
+ *
+ * <p>
+ * This is the mixin-side, response-processor-scoped counterpart to
+ * {@code MixinInheritance_ResponseProcessors_Test} (a02/a04) in {@code 
juneau-integration-tests}, exercised here at the
+ * {@link RestContext} level without a mock client so it runs inside {@code 
juneau-rest-server}'s own test suite.
+ *
+ * @since 10.0.0
+ */
+class MixinResponseProcessorFold_Test extends org.apache.juneau.TestBase {
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // Fixtures
+       
//-----------------------------------------------------------------------------------------------------------
+
+       public static class HostRp1 implements ResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, NotAcceptable, BasicHttpException { return NEXT; }
+       }
+
+       public static class MixinRp1 implements ResponseProcessor {
+               @Override public int process(RestOpSession s) throws 
IOException, NotAcceptable, BasicHttpException { return NEXT; }
+       }
+
+       /** Opted-in mixin: declares the response-processor fold on its own 
class. */
+       @Rest(responseProcessors={MixinRp1.class}, 
mergeResponseProcessorsIntoHost=true)
+       public static class M_OptedIn {
+               @RestGet(path="/my") public String my() { return "my"; }
+       }
+
+       /** Non-opted-in mixin: same response processor, but no fold opt-in 
(regression guard for the non-silent contract). */
+       @Rest(responseProcessors={MixinRp1.class})
+       public static class M_NotOptedIn {
+               @RestGet(path="/my") public String my() { return "my"; }
+       }
+
+       @Rest(responseProcessors={HostRp1.class}, mixins={M_OptedIn.class})
+       public static class HostWithOptedInMixin {
+               @RestGet(path="/h") public String h() { return "h"; }
+       }
+
+       @Rest(responseProcessors={HostRp1.class}, mixins={M_NotOptedIn.class})
+       public static class HostWithNonOptedInMixin {
+               @RestGet(path="/h") public String h() { return "h"; }
+       }
+
+       static RestContext.Args argsOf(Class<?> resourceClass, 
java.util.function.Supplier<?> supplier) {
+               return new RestContext.Args(resourceClass, null, null, 
supplier, null, null, null, null, null, null);
+       }
+
+       private static List<Class<?>> classesOf(ResponseProcessor[] rps) {
+               var l = new ArrayList<Class<?>>(rps.length);
+               for (var rp : rps) l.add(rp.getClass());
+               return l;
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // a - opt-in folds the mixin's response processor into the host chain
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Test void a01_optedInMixin_foldsResponseProcessorIntoHostChain() 
throws Exception {
+               var hostCtx = new 
RestContext(argsOf(HostWithOptedInMixin.class, HostWithOptedInMixin::new));
+               var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+               assertTrue(hostRps.contains(MixinRp1.class),
+                       "Host must fold in the opted-in mixin's MixinRp1 under 
a plain mixins= reference");
+               assertTrue(hostRps.contains(HostRp1.class),
+                       "Host must still register its own HostRp1");
+
+               var mixinCtx = hostCtx.getMixinContexts().get(M_OptedIn.class);
+               assertNotNull(mixinCtx);
+               
assertTrue(classesOf(mixinCtx.getResponseProcessors()).contains(MixinRp1.class),
+                       "Mixin endpoint must still have its own MixinRp1");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // b - non-opted-in mixin keeps today's isolated scoping (non-silent 
contract)
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Test void b01_nonOptedInMixin_keepsIsolatedScoping() throws Exception {
+               var hostCtx = new 
RestContext(argsOf(HostWithNonOptedInMixin.class, 
HostWithNonOptedInMixin::new));
+               var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+               assertFalse(hostRps.contains(MixinRp1.class),
+                       "Host must NOT have MixinRp1 — a mixin without the 
opt-in stays scoped to its own endpoints");
+               assertTrue(hostRps.contains(HostRp1.class),
+                       "Host must still register its own HostRp1");
+
+               var mixinCtx = 
hostCtx.getMixinContexts().get(M_NotOptedIn.class);
+               assertNotNull(mixinCtx);
+               
assertTrue(classesOf(mixinCtx.getResponseProcessors()).contains(MixinRp1.class),
+                       "Mixin endpoint must still have its own MixinRp1 via 
its own @Rest(responseProcessors=)");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // c - ordering / precedence: the host's own processor precedes the 
folded mixin processor
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Test void c01_foldedProcessorAppendedAfterHostChain() throws Exception 
{
+               var hostCtx = new 
RestContext(argsOf(HostWithOptedInMixin.class, HostWithOptedInMixin::new));
+               var hostRps = classesOf(hostCtx.getResponseProcessors());
+
+               var hostIdx = hostRps.indexOf(HostRp1.class);
+               var mixinIdx = hostRps.indexOf(MixinRp1.class);
+               assertTrue(hostIdx >= 0 && mixinIdx >= 0, "Both processors must 
be present");
+               assertTrue(hostIdx < mixinIdx,
+                       "Host's own HostRp1 must precede the folded MixinRp1 
(append semantics)");
+       }
+}
diff --git 
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
 
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
index 61762ba263..0d5eafc5e9 100644
--- 
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
+++ 
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/ConfigItem.java
@@ -46,6 +46,7 @@ public class ConfigItem {
         * Sets the configuration value.
         *
         * @param value The value to set.
+        * @return This object.
         */
-       public void setValue(String value) { this.value = value; }
+       public ConfigItem setValue(String value) { this.value = value; return 
this; }
 }
\ No newline at end of file

Reply via email to