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

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


The following commit(s) were added to refs/heads/main by this push:
     new 831969db79 TIKA-4965: @OperatorOnly replaces per-parser RuntimeConfig 
subclasses (#3314)
831969db79 is described below

commit 831969db79962fae6eebf9a578d33209568b4ba5
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 9 14:39:10 2026 -0400

    TIKA-4965: @OperatorOnly replaces per-parser RuntimeConfig subclasses 
(#3314)
---
 CHANGES.txt                                        |   6 +
 .../pages/configuration/parsers/tess4j-parser.adoc |  11 +-
 .../pages/configuration/parsers/vlm-parsers.adoc   |   6 +-
 .../ROOT/pages/developers/serialization.adoc       |  35 ++++-
 .../apache/tika/config/OperatorOnlyAuditTest.java  |  51 +++++++
 .../java/org/apache/tika/config/OperatorOnly.java  |  48 ++++++
 .../apache/tika/config/OperatorOnlyAuditTest.java  |  64 ++++++++
 .../apache/tika/detect/magika/MagikaDetector.java  |  20 +--
 .../tika/detect/siegfried/SiegfriedDetector.java   |  20 +--
 .../tika/inference/AbstractEmbeddingFilter.java    |  10 +-
 .../tika/inference/ImageEmbeddingConfig.java       |  44 +-----
 .../org/apache/tika/inference/InferenceConfig.java |  60 +-------
 .../tika/inference/OpenAIImageEmbeddingParser.java |  10 --
 .../ImageEmbeddingOperatorOnlyConfigTest.java      |  67 ++++++++
 .../ImageEmbeddingRuntimeConfigMergeTest.java      |  78 ----------
 .../apache/tika/parser/ctakes/CTAKESConfig.java    |   3 +
 .../tika/parser/geo/topic/GeoParserConfig.java     |  48 +-----
 .../tika/parser/ocr/tess4j/Tess4JConfig.java       |  58 +------
 .../tika/parser/ocr/tess4j/Tess4JParser.java       |  11 --
 .../tika/parser/ocr/tess4j/Tess4JConfigTest.java   |  15 --
 .../ocr/tess4j/Tess4JOperatorOnlyConfigTest.java   |  66 ++++++++
 .../ocr/tess4j/Tess4JRuntimeConfigMergeTest.java   |  76 ---------
 .../transcribe/aws/AmazonTranscribeConfig.java     |  59 +------
 .../apache/tika/parser/vlm/AbstractVLMParser.java  |  41 +++--
 .../org/apache/tika/parser/vlm/VLMOCRConfig.java   | 128 +---------------
 .../tika/parser/vlm/VLMOperatorOnlyConfigTest.java | 113 ++++++++++++++
 .../tika/parser/vlm/VLMRuntimeConfigMergeTest.java | 118 --------------
 .../tika/parser/ocr/TesseractOCRParserTest.java    |  96 ++++--------
 .../apache/tika/parser/dwg/DWGParserConfig.java    |  27 +---
 .../microsoft/libpst/LibPstParserConfig.java       |  28 +---
 .../apache/tika/parser/ocr/TesseractOCRConfig.java |  56 +------
 .../apache/tika/parser/ocr/TesseractOCRParser.java |  13 +-
 .../tika/parser/ocr/TesseractOCRConfigTest.java    |  16 --
 .../java/org/apache/tika/parser/pdf/PDFParser.java |  10 --
 .../apache/tika/parser/pdf/PDFParserConfig.java    |  37 +----
 .../apache/tika/parser/strings/StringsConfig.java  |  38 +----
 .../apache/tika/config/loader/JsonMergeUtils.java  |  10 +-
 .../tika/serialization/ComponentNameResolver.java  |   4 +-
 .../tika/serialization/ConfigDeserializer.java     |   4 +
 .../tika/serialization/OperatorOnlyGuard.java      | 126 +++++++++++++++
 .../tika/config/loader/JsonMergeUtilsTest.java     |   2 +-
 .../tika/serialization/ConfigDeserializerTest.java |   6 +-
 .../tika/serialization/OperatorOnlyGuardTest.java  | 154 +++++++++++++++++++
 .../tika/server/standard/TikaResourceTest.java     |  33 ++++
 .../org/apache/tika/test/OperatorOnlyAudit.java    | 169 +++++++++++++++++++++
 45 files changed, 1073 insertions(+), 1022 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index c6fd60ed8c..a0e04e1857 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,11 @@
 Release 4.2.0 - unreleased
 
+   * Config properties whose value reaches the host (paths, executables, class 
names,
+     URLs, credentials, operator-sized limits) are marked @OperatorOnly and 
refused in
+     per-request JSON before any setter runs. Newly locked: pdf-parser's
+     imageGraphicsEngineFactoryClass and the VLM parsers' completionsPath. The 
per-parser
+     RuntimeConfig subclasses are removed (TIKA-4965).
+
    * JSoupParser emits the text of CDATA sections again. jsoup 1.23 tokenizes 
CDATA in
      HTML content as a bogus comment, which silently dropped every CDATA value 
in XML
      that reaches the HTML parser (TIKA-4963).
diff --git a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
index 0f0b2c05a1..55050b02f9 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
@@ -280,9 +280,8 @@ context.set(Tess4JConfig.class, override);
 ----
 
 NOTE: In caller-supplied JSON config — the tika-server `/config` endpoints, 
tika-grpc's
-`parse_context_json` — `dataPath` and `nativeLibPath` are locked at parser 
initialization, and
-setting either throws `TikaConfigException`. They point at native code, so a 
caller must not be
-able to move them. `poolSize` and `maxImagePixels` are locked in the same way 
but throw
-`IllegalStateException` -- for different reasons. The OCR pool is sized at 
initialization and
-cannot be resized per parse. `maxImagePixels` *is* read per parse, but it is 
the guard that
-bounds image decode against OOM, so a caller must not be able to raise it.
+`parse_context_json` — `dataPath`, `nativeLibPath`, `poolSize` and 
`maxImagePixels` are
+operator-only: a per-request block that names one is rejected before the 
parser sees it. The
+paths point at native code, so a caller must not be able to move them. The OCR 
pool is sized at
+initialization and cannot be resized per parse. `maxImagePixels` *is* read per 
parse, but it is
+the guard that bounds image decode against OOM, so a caller must not be able 
to raise it.
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index a9e5e4e721..1e3aa0cef9 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -279,14 +279,14 @@ context.set(VLMOCRConfig.class, override);
 ----
 
 *Caller-supplied JSON* — the tika-server `/config` endpoints and tika-grpc's 
`parse_context_json`,
-each gated by its own `allowPerRequestConfig`. Here the parser applies a 
restricted config that
-rejects cost- and security-sensitive changes with an exception:
+each gated by its own `allowPerRequestConfig`. Here cost- and 
security-sensitive changes are
+rejected with an exception:
 
 [cols="1,3"]
 |===
 |Field |Runtime rule
 
-|`baseUrl`, `apiKey`
+|`baseUrl`, `completionsPath`, `apiKey`
 |Always blocked; configure at initialization time.
 
 |`model`
diff --git a/docs/modules/ROOT/pages/developers/serialization.adoc 
b/docs/modules/ROOT/pages/developers/serialization.adoc
index a6ff9fe9a0..06322b87c1 100644
--- a/docs/modules/ROOT/pages/developers/serialization.adoc
+++ b/docs/modules/ROOT/pages/developers/serialization.adoc
@@ -263,7 +263,10 @@ That is why per-request configuration is gated separately 
by
 `allowPerRequestConfig`, which is off by default. Treat "the caller may supply
 per-request config" as equivalent to "the caller may set any parser option,
 including options that spawn external processes such as OCR" — not as something
-the allowlist constrains.
+the allowlist constrains. The one exception is a property marked 
`@OperatorOnly`
+(a path, executable, class name, URL, credential or operator-sized limit; see
+<<_operator_only_properties,Operator-only properties>>): a per-request block
+that names one is refused.
 ====
 
 [source,json]
@@ -303,6 +306,36 @@ exhaustiveness test asserts every context-key interface is 
classified as
 exactly one of the two. Plain config DTOs (non-component keys) are never
 blocked.
 
+[#_operator_only_properties]
+=== Operator-only properties
+
+Restricted mode decides which components a request may bind. It cannot see 
inside a
+self-configuring component's config: every `Parser` is self-configuring, so a 
per-request
+`pdf-parser` block reaches `PDFParserConfig` unexamined. A property whose 
value reaches the
+host rather than the document — a path or executable the parser runs, a class 
name it loads,
+a URL or credential it sends data to, a pool size or memory ceiling the 
operator sized — is
+therefore marked on the config class:
+
+[source,java]
+----
+@OperatorOnly
+public void setTesseractPath(String tesseractPath) throws TikaConfigException 
{ ... }
+----
+
+`ConfigDeserializer` refuses per-request JSON (`JsonConfig.trusted()` false: a 
tika-server
+`/config` part, a pipes `FetchEmitTuple` parse context) that names such a 
property. The check
+is by JSON key, before any setter runs, and surfaces as an `IOException` that 
names the
+property. Operator JSON — tika-config.json, including its presets — is trusted 
and unaffected.
+A nested config object is checked against its own type, and an override that 
drops the
+annotation does not unlock the property.
+
+Writing a config class: annotate the setter of anything a caller must not be 
able to point
+elsewhere. A rule the annotation cannot express — a ceiling a request may 
lower but not raise,
+a field that unlocks when the operator opts in — belongs in the component 
after the merge, as
+`AbstractVLMParser` does for `maxTokens` and `prompt`. tika-app's 
`OperatorOnlyAuditTest` fails
+on any `*Config` setter on its classpath whose name says path, executable, 
class, URL,
+credential or model but carries no annotation.
+
 == Framework Directives
 
 Some JSON keys are consumed by the loading framework itself rather than by the
diff --git 
a/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java 
b/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
new file mode 100644
index 0000000000..ac1576b9a1
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.tika.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.test.OperatorOnlyAudit;
+
+/**
+ * Every config setter on the tika-app classpath that looks like it reaches 
the host carries
+ * {@code @OperatorOnly}, so per-request JSON cannot set it. A hit here is 
either a real hole
+ * (annotate the setter) or a reviewed false positive (annotate it anyway, or 
list it below
+ * with the reason).
+ */
+public class OperatorOnlyAuditTest {
+
+    /** Config that is loaded only from the operator's files, never resolved 
per request. */
+    private static final List<String> NOT_PER_REQUEST = List.of(
+            "org.apache.tika.server.",
+            "org.apache.tika.pipes.",
+            "org.apache.tika.cli.",
+            "org.apache.tika.eval.");
+
+    private static final List<String> REVIEWED = List.of();
+
+    @Test
+    public void testSensitiveSettersAreOperatorOnly() throws Exception {
+        List<String> violations =
+                OperatorOnlyAudit.unannotatedSensitiveSetters(NOT_PER_REQUEST, 
REVIEWED);
+        assertEquals(List.of(), violations,
+                "setters that take a host-side value but allow per-request 
override");
+    }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java 
b/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
new file mode 100644
index 0000000000..778fec73c3
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/config/OperatorOnly.java
@@ -0,0 +1,48 @@
+/*
+ * 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.tika.config;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a configuration property that only operator-authored configuration 
(the config
+ * file, presets) may set. Per-request JSON ({@link JsonConfig#trusted()} 
false, which is
+ * what a tika-server {@code /config} part or a pipes {@code FetchEmitTuple} 
carries) that
+ * names the property is refused before any setter runs.
+ * <p>
+ * Put it on the setter (or field, or getter) of anything whose value reaches 
the host
+ * rather than the document: a path or executable the parser runs, a class 
name it loads,
+ * a URL or credential it sends data to, a pool size or memory ceiling an 
operator sized.
+ * <pre>
+ * {@literal @}OperatorOnly
+ * public void setTesseractPath(String tesseractPath) { ... }
+ * </pre>
+ * Enforced by {@code ConfigDeserializer} (tika-serialization) for every 
component that
+ * resolves its config through {@link ParseContextConfig#getConfig}. The check 
is by JSON
+ * key, so a nested object's properties are checked against the nested type 
too.
+ *
+ * @since Apache Tika 4.2
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD, ElementType.FIELD})
+public @interface OperatorOnly {
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java 
b/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
new file mode 100644
index 0000000000..3b62d91908
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/config/OperatorOnlyAuditTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.tika.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.test.OperatorOnlyAudit;
+
+/** The audit reports an unannotated host-reaching setter and nothing else 
from this class. */
+public class OperatorOnlyAuditTest {
+
+    public static class UnguardedConfig {
+        public void setToolPath(String path) {
+        }
+
+        public void setLanguage(String language) {
+        }
+
+        @OperatorOnly
+        public void setApiKey(String key) {
+        }
+
+        public void setModel(String model) {
+        }
+
+        @OperatorOnly
+        public String getModel() {
+            return null;
+        }
+    }
+
+    @Test
+    public void testReportsOnlyTheUnannotatedSensitiveSetter() throws 
Exception {
+        List<String> violations = 
OperatorOnlyAudit.unannotatedSensitiveSetters(List.of(), List.of());
+        // the one unguarded setter here, and nothing from tika-core itself
+        assertEquals(List.of(UnguardedConfig.class.getName() + 
"#setToolPath"), violations);
+    }
+
+    @Test
+    public void testAllowlistSilencesAReviewedSetter() throws Exception {
+        String reviewed = UnguardedConfig.class.getName() + "#setToolPath";
+        assertFalse(OperatorOnlyAudit.unannotatedSensitiveSetters(List.of(), 
List.of(reviewed))
+                .contains(reviewed));
+    }
+}
diff --git 
a/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
 
b/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
index 8a886ee0b5..96171fd538 100644
--- 
a/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
+++ 
b/tika-detectors/tika-detector-magika/src/main/java/org/apache/tika/detect/magika/MagikaDetector.java
@@ -30,6 +30,7 @@ import org.slf4j.LoggerFactory;
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.config.ConfigDeserializer;
 import org.apache.tika.config.JsonConfig;
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.detect.Detector;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.ExternalProcess;
@@ -102,6 +103,7 @@ public class MagikaDetector implements Detector {
             return magikaPath;
         }
 
+        @OperatorOnly
         public void setMagikaPath(String magikaPath) {
             this.magikaPath = magikaPath;
         }
@@ -131,24 +133,6 @@ public class MagikaDetector implements Detector {
         }
     }
 
-    /**
-     * RuntimeConfig blocks modification of security-sensitive path fields at 
runtime.
-     */
-    public static class RuntimeConfig extends Config {
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setMagikaPath(String magikaPath) {
-            if (!StringUtils.isBlank(magikaPath)) {
-                throw new IllegalArgumentException(
-                        "Cannot modify magikaPath at runtime. " +
-                                "Paths must be configured at detector 
initialization time.");
-            }
-        }
-    }
-
     private final Config defaultConfig;
 
     /**
diff --git 
a/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
 
b/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
index 8a616d9e56..a5057e90cc 100644
--- 
a/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
+++ 
b/tika-detectors/tika-detector-siegfried/src/main/java/org/apache/tika/detect/siegfried/SiegfriedDetector.java
@@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory;
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.config.ConfigDeserializer;
 import org.apache.tika.config.JsonConfig;
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.detect.Detector;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.ExternalProcess;
@@ -101,6 +102,7 @@ public class SiegfriedDetector implements Detector {
             return siegfriedPath;
         }
 
+        @OperatorOnly
         public void setSiegfriedPath(String siegfriedPath) {
             this.siegfriedPath = siegfriedPath;
         }
@@ -130,24 +132,6 @@ public class SiegfriedDetector implements Detector {
         }
     }
 
-    /**
-     * RuntimeConfig blocks modification of security-sensitive path fields at 
runtime.
-     */
-    public static class RuntimeConfig extends Config {
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setSiegfriedPath(String siegfriedPath) {
-            if (!StringUtils.isBlank(siegfriedPath)) {
-                throw new IllegalArgumentException(
-                        "Cannot modify siegfriedPath at runtime. " +
-                                "Paths must be configured at detector 
initialization time.");
-            }
-        }
-    }
-
     private final Config defaultConfig;
 
     /**
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
index a40448189b..cf1a4f72b4 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
@@ -122,20 +122,14 @@ public abstract class AbstractEmbeddingFilter extends 
MetadataFilter implements
     }
 
     /**
-     * Per-request JSON config, validated through {@link 
InferenceConfig.RuntimeConfig}
-     * (which rejects baseUrl/apiKey/model changes) and merged over the 
init-time defaults.
+     * Per-request JSON config merged over the init-time defaults; the 
deserializer refuses
+     * the {@code @OperatorOnly} fields (baseUrl, apiKey, model, batch and 
chunk limits).
      * With no JSON, a class-keyed programmatic {@link InferenceConfig} is 
honored for
      * skipEmbedding only, preserving the pre-4.1 contract.
      */
     private InferenceConfig resolveConfig(ParseContext parseContext) throws 
TikaException {
         try {
             if (ParseContextConfig.hasConfig(parseContext, 
getComponentName())) {
-                InferenceConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                        parseContext, getComponentName(),
-                        InferenceConfig.RuntimeConfig.class, new 
InferenceConfig.RuntimeConfig());
-                if (runtimeConfig.isSkipEmbedding()) {
-                    return runtimeConfig;
-                }
                 return ParseContextConfig.getConfig(parseContext, 
getComponentName(),
                         InferenceConfig.class, defaultConfig);
             }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
index f0589ccd7c..b9b72b2497 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
@@ -18,9 +18,9 @@ package org.apache.tika.inference;
 
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.utils.StringUtils;
 
 /**
  * Configuration for image embedding parsers that call a CLIP-like
@@ -59,6 +59,7 @@ public class ImageEmbeddingConfig implements Serializable {
         return baseUrl;
     }
 
+    @OperatorOnly
     public void setBaseUrl(String baseUrl) throws TikaConfigException {
         this.baseUrl = baseUrl;
     }
@@ -67,6 +68,7 @@ public class ImageEmbeddingConfig implements Serializable {
         return model;
     }
 
+    @OperatorOnly
     public void setModel(String model) {
         this.model = model;
     }
@@ -75,6 +77,7 @@ public class ImageEmbeddingConfig implements Serializable {
         return apiKey;
     }
 
+    @OperatorOnly
     public void setApiKey(String apiKey) throws TikaConfigException {
         this.apiKey = apiKey;
     }
@@ -126,43 +129,4 @@ public class ImageEmbeddingConfig implements Serializable {
     public void setLiftToParent(boolean liftToParent) {
         this.liftToParent = liftToParent;
     }
-
-    /**
-     * Runtime-only config that prevents modification of security-sensitive
-     * and cost-sensitive fields ({@code baseUrl}, {@code apiKey},
-     * {@code model}) at parse time.
-     * <p>
-     * These fields must be set at initialization via the config file.
-     * If a runtime {@code ParseContext} JSON config attempts to set them,
-     * the overridden setters throw {@link TikaConfigException}.
-     */
-    public static class RuntimeConfig extends ImageEmbeddingConfig {
-
-        @Override
-        public void setBaseUrl(String baseUrl) throws TikaConfigException {
-            if (!StringUtils.isBlank(baseUrl)) {
-                throw new TikaConfigException(
-                        "Cannot modify baseUrl at runtime. "
-                                + "URLs must be configured at initialization 
time.");
-            }
-        }
-
-        @Override
-        public void setApiKey(String apiKey) throws TikaConfigException {
-            if (!StringUtils.isBlank(apiKey)) {
-                throw new TikaConfigException(
-                        "Cannot modify apiKey at runtime. "
-                                + "API keys must be configured at 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setModel(String model) {
-            throw new IllegalStateException(
-                    "Cannot modify model at runtime. "
-                            + "Models must be configured at initialization 
time. "
-                            + "If you need a different model, configure a "
-                            + "separate parser instance.");
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
index bed3d98e06..93a6b97dbf 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/InferenceConfig.java
@@ -18,9 +18,9 @@ package org.apache.tika.inference;
 
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.utils.StringUtils;
 
 /**
  * Configuration for the inference metadata filters.
@@ -116,6 +116,7 @@ public class InferenceConfig implements Serializable {
         return baseUrl;
     }
 
+    @OperatorOnly
     public void setBaseUrl(String baseUrl) throws TikaConfigException {
         this.baseUrl = baseUrl;
     }
@@ -124,6 +125,7 @@ public class InferenceConfig implements Serializable {
         return model;
     }
 
+    @OperatorOnly
     public void setModel(String model) {
         this.model = model;
     }
@@ -132,6 +134,7 @@ public class InferenceConfig implements Serializable {
         return apiKey;
     }
 
+    @OperatorOnly
     public void setApiKey(String apiKey) throws TikaConfigException {
         this.apiKey = apiKey;
     }
@@ -200,6 +203,7 @@ public class InferenceConfig implements Serializable {
      * Set the maximum number of chunks per embeddings API request.
      * Must be at least 1.
      */
+    @OperatorOnly
     public void setMaxBatchSize(int maxBatchSize) {
         if (maxBatchSize < 1) {
             throw new IllegalArgumentException(
@@ -216,6 +220,7 @@ public class InferenceConfig implements Serializable {
      * Set the maximum number of chunks per document.
      * Set to {@code -1} for no limit. Must be {@code -1} or at least {@code 
1}.
      */
+    @OperatorOnly
     public void setMaxChunks(int maxChunks) {
         if (maxChunks < 1 && maxChunks != -1) {
             throw new IllegalArgumentException(
@@ -223,57 +228,4 @@ public class InferenceConfig implements Serializable {
         }
         this.maxChunks = maxChunks;
     }
-
-    /**
-     * Runtime-only config that prevents modification of security-sensitive
-     * and cost-sensitive fields ({@code baseUrl}, {@code apiKey},
-     * {@code model}) at parse time.
-     * <p>
-     * These fields must be set at initialization via the config file.
-     * If a runtime {@code ParseContext} JSON config attempts to set them,
-     * the overridden setters throw {@link TikaConfigException}.
-     */
-    public static class RuntimeConfig extends InferenceConfig {
-
-        @Override
-        public void setBaseUrl(String baseUrl) throws TikaConfigException {
-            if (!StringUtils.isBlank(baseUrl)) {
-                throw new TikaConfigException(
-                        "Cannot modify baseUrl at runtime. "
-                                + "URLs must be configured at initialization 
time.");
-            }
-        }
-
-        @Override
-        public void setApiKey(String apiKey) throws TikaConfigException {
-            if (!StringUtils.isBlank(apiKey)) {
-                throw new TikaConfigException(
-                        "Cannot modify apiKey at runtime. "
-                                + "API keys must be configured at 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setModel(String model) {
-            throw new IllegalStateException(
-                    "Cannot modify model at runtime. "
-                            + "Models must be configured at initialization 
time. "
-                            + "If you need a different model, configure a "
-                            + "separate filter instance.");
-        }
-
-        @Override
-        public void setMaxBatchSize(int maxBatchSize) {
-            throw new IllegalStateException(
-                    "Cannot modify maxBatchSize at runtime. "
-                            + "Batch size must be configured at initialization 
time.");
-        }
-
-        @Override
-        public void setMaxChunks(int maxChunks) {
-            throw new IllegalStateException(
-                    "Cannot modify maxChunks at runtime. "
-                            + "Chunk limits must be configured at 
initialization time.");
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
index af96034cca..692c1196ac 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
@@ -294,16 +294,6 @@ public class OpenAIImageEmbeddingParser implements Parser, 
Initializable, Closea
             throws TikaConfigException, IOException {
         String key = "openai-image-embedding-parser";
         if (parseContext.hasJsonConfig(key)) {
-            ImageEmbeddingConfig.RuntimeConfig runtimeConfig =
-                    ParseContextConfig.getConfig(
-                            parseContext, key,
-                            ImageEmbeddingConfig.RuntimeConfig.class,
-                            new ImageEmbeddingConfig.RuntimeConfig());
-
-            if (runtimeConfig.isSkipEmbedding()) {
-                return runtimeConfig;
-            }
-
             return ParseContextConfig.getConfig(
                     parseContext, key, ImageEmbeddingConfig.class,
                     defaultConfig);
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..780902ef92
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingOperatorOnlyConfigTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.tika.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The endpoint, credential and model are where document bytes get sent and 
billed, so a
+ * per-request block may not set them; operator JSON may.
+ */
+public class ImageEmbeddingOperatorOnlyConfigTest {
+
+    private static final String KEY = "openai-image-embedding-parser";
+
+    private ImageEmbeddingConfig resolve(String json, boolean trusted) throws 
Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig(KEY, json, trusted);
+        return ParseContextConfig.getConfig(context, KEY, 
ImageEmbeddingConfig.class,
+                new ImageEmbeddingConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new ImageEmbeddingConfig().getBaseUrl(), resolve("{}", 
false).getBaseUrl());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(resolve("{\"skipEmbedding\": true}", 
false).isSkipEmbedding());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"model\": \"evil\"}", "{\"baseUrl\": 
\"http://evil\"}";,
+            "{\"apiKey\": \"evil\"}"})
+    public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+        Exception e = assertThrows(Exception.class, () -> resolve(json, 
false));
+        assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+    }
+
+    @Test
+    public void testOperatorConfigMaySetThem() throws Exception {
+        assertEquals("http://internal";, resolve("{\"baseUrl\": 
\"http://internal\"}";, true).getBaseUrl());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
deleted file mode 100644
index 9300600db6..0000000000
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * 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.tika.inference;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * The non-blank {@code baseUrl} default trips this config's own "reject any 
non-empty
- * value" guard when the merge copies the defaults through the setters 
(TIKA-4843).
- */
-public class ImageEmbeddingRuntimeConfigMergeTest {
-
-    private static final String KEY = "openai-image-embedding-parser";
-
-    private ImageEmbeddingConfig runtime(String json) throws Exception {
-        ParseContext context = new ParseContext();
-        context.setJsonConfig(KEY, json);
-        return ParseContextConfig.getConfig(context, KEY,
-                ImageEmbeddingConfig.RuntimeConfig.class, new 
ImageEmbeddingConfig.RuntimeConfig());
-    }
-
-    @Test
-    public void testEmptyConfigMerges() throws Exception {
-        assertEquals(new ImageEmbeddingConfig().getBaseUrl(), 
runtime("{}").getBaseUrl());
-    }
-
-    @Test
-    public void testUnrelatedFieldMerges() throws Exception {
-        assertTrue(runtime("{\"skipEmbedding\": true}").isSkipEmbedding());
-    }
-
-    @Test
-    public void testCallerSetModelStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> runtime("{\"model\": 
\"evil\"}"));
-        assertTrue(rootMessage(e).contains("Cannot modify model"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetBaseUrlStillRejected() {
-        Exception e = assertThrows(Exception.class,
-                () -> runtime("{\"baseUrl\": \"http://evil\"}";));
-        assertTrue(rootMessage(e).contains("Cannot modify baseUrl"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetApiKeyStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"apiKey\": \"evil\"}"));
-        assertTrue(rootMessage(e).contains("Cannot modify apiKey"), 
rootMessage(e));
-    }
-
-    private static String rootMessage(Throwable t) {
-        while (t.getCause() != null) {
-            t = t.getCause();
-        }
-        return String.valueOf(t.getMessage());
-    }
-}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
index 761e4d6e38..69f67dcd26 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/ctakes/CTAKESConfig.java
@@ -24,6 +24,8 @@ import java.util.Properties;
 
 import org.apache.commons.io.output.NullOutputStream;
 
+import org.apache.tika.config.OperatorOnly;
+
 /**
  * Configuration for {@link CTAKESContentHandler}.
  * <p>
@@ -128,6 +130,7 @@ public class CTAKESConfig implements Serializable {
      *
      * @param aeDescriptorPath the path to XML descriptor for AnalysisEngine.
      */
+    @OperatorOnly
     public void setAeDescriptorPath(String aeDescriptorPath) {
         this.aeDescriptorPath = aeDescriptorPath;
     }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
index 44d5d5b97b..de2878d86f 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-nlp-module/src/main/java/org/apache/tika/parser/geo/topic/GeoParserConfig.java
@@ -27,7 +27,7 @@ import java.util.Properties;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import org.apache.tika.utils.StringUtils;
+import org.apache.tika.config.OperatorOnly;
 
 public class GeoParserConfig implements Serializable {
     private static final long serialVersionUID = -3167692634278575818L;
@@ -67,6 +67,7 @@ public class GeoParserConfig implements Serializable {
         setGazetteerRestEndpoint(props.getProperty("gazetter.rest.api", 
"http://localhost:8765";));
     }
 
+    @OperatorOnly
     public void setNERModelPath(String path) {
         if (path == null) {
             return;
@@ -86,6 +87,7 @@ public class GeoParserConfig implements Serializable {
         return nerModelUrl;
     }
 
+    @OperatorOnly
     public void setNerModelUrl(URL url) {
         this.nerModelUrl = url;
     }
@@ -102,50 +104,8 @@ public class GeoParserConfig implements Serializable {
      *
      * @param gazetteerRestEndpoint
      */
+    @OperatorOnly
     public void setGazetteerRestEndpoint(String gazetteerRestEndpoint) {
         this.gazetteerRestEndpoint = gazetteerRestEndpoint;
     }
-
-    /**
-     * RuntimeConfig blocks modification of security-sensitive URL/path fields 
at runtime.
-     * When a config is obtained from ParseContext (i.e. user-provided at 
parse time),
-     * it should be deserialized as a RuntimeConfig to prevent URL/path 
injection.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any 
URL/path fields, the
-     * overridden setters will throw TikaConfigException.
-     */
-    public static class RuntimeConfig extends GeoParserConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setNerModelUrl(URL url) {
-            if (url != null) {
-                throw new IllegalArgumentException(
-                        "Cannot modify nerModelUrl at runtime. " +
-                                "URLs must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setNERModelPath(String path) {
-            if (!StringUtils.isBlank(path)) {
-                throw new IllegalArgumentException(
-                        "Cannot modify NER model path at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setGazetteerRestEndpoint(String gazetteerRestEndpoint) {
-            if (!StringUtils.isBlank(gazetteerRestEndpoint)) {
-                throw new IllegalArgumentException(
-                        "Cannot modify gazetteerRestEndpoint at runtime. " +
-                                "URLs must be configured at parser 
initialization time.");
-            }
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
index fb29d4374e..2b227bf42d 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfig.java
@@ -20,6 +20,7 @@ import java.io.Serializable;
 import java.util.HashSet;
 import java.util.Set;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.utils.StringUtils;
 
@@ -146,6 +147,7 @@ public class Tess4JConfig implements Serializable {
     /**
      * Set the path to the tessdata directory.
      */
+    @OperatorOnly
     public void setDataPath(String dataPath) throws TikaConfigException {
         this.dataPath = dataPath;
     }
@@ -208,6 +210,7 @@ public class Tess4JConfig implements Serializable {
      * Set the number of Tesseract instances to keep in the pool.
      * Default is 2. Must be at least 1.
      */
+    @OperatorOnly
     public void setPoolSize(int poolSize) {
         if (poolSize < 1) {
             throw new IllegalArgumentException("Pool size must be at least 1, 
got: " + poolSize);
@@ -258,6 +261,7 @@ public class Tess4JConfig implements Serializable {
      * an image before OCR is skipped. Default is 100,000,000
      * (100 megapixels). Set to {@code -1} for no limit (not recommended).
      */
+    @OperatorOnly
     public void setMaxImagePixels(long maxImagePixels) {
         if (maxImagePixels < 1 && maxImagePixels != -1) {
             throw new IllegalArgumentException(
@@ -275,6 +279,7 @@ public class Tess4JConfig implements Serializable {
      * Set the path to the directory containing native Tesseract/Leptonica 
shared libraries.
      * On macOS with Homebrew this is typically {@code /opt/homebrew/lib}.
      */
+    @OperatorOnly
     public void setNativeLibPath(String nativeLibPath) throws 
TikaConfigException {
         this.nativeLibPath = nativeLibPath;
     }
@@ -301,57 +306,4 @@ public class Tess4JConfig implements Serializable {
             }
         }
     }
-
-    /**
-     * Runtime-only Tess4JConfig that prevents modification of paths and
-     * pool settings during parse-time configuration.
-     * <p>
-     * <b>Always blocked:</b> {@code dataPath}, {@code nativeLibPath},
-     * {@code poolSize}, {@code maxImagePixels}.
-     * <p>
-     * Paths are blocked to prevent file-system access attacks.
-     * Pool size is blocked because the pool is built at init time and cannot
-     * be resized.  {@code maxImagePixels} is read per parse, but it is the
-     * OOM guard on image decode, so a caller must not be able to raise it.
-     */
-    public static class RuntimeConfig extends Tess4JConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setDataPath(String dataPath) throws TikaConfigException {
-            if (!StringUtils.isBlank(dataPath)) {
-                throw new TikaConfigException(
-                        "Cannot modify dataPath at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setNativeLibPath(String nativeLibPath) throws 
TikaConfigException {
-            if (!StringUtils.isBlank(nativeLibPath)) {
-                throw new TikaConfigException(
-                        "Cannot modify nativeLibPath at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setPoolSize(int poolSize) {
-            throw new IllegalStateException(
-                    "Cannot modify poolSize at runtime. " +
-                            "The pool is created at initialization time " +
-                            "and cannot be resized.");
-        }
-
-        @Override
-        public void setMaxImagePixels(long maxImagePixels) {
-            throw new IllegalStateException(
-                    "Cannot modify maxImagePixels at runtime. " +
-                            "Image size limits must be configured at " +
-                            "initialization time.");
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
index e3006a6356..68b1d64d33 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
@@ -506,17 +506,6 @@ public class Tess4JParser implements Parser, 
Initializable, TextRecognizer {
             throws TikaConfigException, IOException {
 
         if (parseContext.hasJsonConfig("tess4j-parser")) {
-            // Validate no paths in runtime config
-            Tess4JConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                    parseContext,
-                    "tess4j-parser",
-                    Tess4JConfig.RuntimeConfig.class,
-                    new Tess4JConfig.RuntimeConfig());
-
-            if (runtimeConfig.isSkipOcr()) {
-                return runtimeConfig;
-            }
-
             return ParseContextConfig.getConfig(
                     parseContext,
                     "tess4j-parser",
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
index 3a4f31d455..3f8ff5f8dc 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JConfigTest.java
@@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.junit.jupiter.api.Test;
 
-import org.apache.tika.exception.TikaConfigException;
 
 public class Tess4JConfigTest {
 
@@ -134,18 +133,4 @@ public class Tess4JConfigTest {
         config.setSkipOcr(true);
         assertTrue(config.isSkipOcr());
     }
-
-    @Test
-    public void testRuntimeConfigBlocksDataPath() {
-        Tess4JConfig.RuntimeConfig config = new Tess4JConfig.RuntimeConfig();
-        assertThrows(TikaConfigException.class,
-                () -> config.setDataPath("/some/path"));
-    }
-
-    @Test
-    public void testRuntimeConfigAllowsEmptyDataPath() throws 
TikaConfigException {
-        Tess4JConfig.RuntimeConfig config = new Tess4JConfig.RuntimeConfig();
-        config.setDataPath("");
-        assertEquals("", config.getDataPath());
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..7ec6fb29f6
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JOperatorOnlyConfigTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.tika.parser.ocr.tess4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Paths point at native code and the pool and pixel ceilings are operator 
sizing, so a
+ * per-request {@code tess4j-parser} block may not set them; operator JSON may.
+ */
+public class Tess4JOperatorOnlyConfigTest {
+
+    private static final String KEY = "tess4j-parser";
+
+    private Tess4JConfig resolve(String json, boolean trusted) throws 
Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig(KEY, json, trusted);
+        return ParseContextConfig.getConfig(context, KEY, Tess4JConfig.class, 
new Tess4JConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new Tess4JConfig().getPoolSize(), resolve("{}", 
false).getPoolSize());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(resolve("{\"skipOcr\": true}", false).isSkipOcr());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"poolSize\": 7}", "{\"maxImagePixels\": 5}",
+            "{\"dataPath\": \"/tmp/evil\"}", "{\"nativeLibPath\": 
\"/tmp/evil\"}"})
+    public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+        Exception e = assertThrows(Exception.class, () -> resolve(json, 
false));
+        assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+    }
+
+    @Test
+    public void testOperatorConfigMaySetThem() throws Exception {
+        assertEquals(7, resolve("{\"poolSize\": 7}", true).getPoolSize());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
deleted file mode 100644
index 5e232845dd..0000000000
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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.tika.parser.ocr.tess4j;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * Locked fields must be rejected only when the caller actually sets them. The 
merge
- * clones the default first, and that clone must not trip the guards -- 
otherwise every
- * per-request config throws, including {@code {}}.
- */
-public class Tess4JRuntimeConfigMergeTest {
-
-    private Tess4JConfig runtime(String json) throws Exception {
-        ParseContext context = new ParseContext();
-        context.setJsonConfig("tess4j-parser", json);
-        return ParseContextConfig.getConfig(context, "tess4j-parser",
-                Tess4JConfig.RuntimeConfig.class, new 
Tess4JConfig.RuntimeConfig());
-    }
-
-    @Test
-    public void testEmptyConfigMerges() throws Exception {
-        assertEquals(new Tess4JConfig().getPoolSize(), 
runtime("{}").getPoolSize());
-    }
-
-    @Test
-    public void testUnrelatedFieldMerges() throws Exception {
-        assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
-    }
-
-    @Test
-    public void testCallerSetPoolSizeStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"poolSize\": 7}"));
-        assertTrue(rootMessage(e).contains("Cannot modify poolSize"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetMaxImagePixelsStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"maxImagePixels\": 5}"));
-        assertTrue(rootMessage(e).contains("Cannot modify maxImagePixels"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetDataPathStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"dataPath\": \"/tmp/evil\"}"));
-        assertTrue(rootMessage(e).contains("Cannot modify dataPath"), 
rootMessage(e));
-    }
-
-    private static String rootMessage(Throwable t) {
-        while (t.getCause() != null) {
-            t = t.getCause();
-        }
-        return String.valueOf(t.getMessage());
-    }
-}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
index b1011b7d30..86a5d14459 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-transcribe-aws/src/main/java/org/apache/tika/parser/transcribe/aws/AmazonTranscribeConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.transcribe.aws;
 
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
 
 public class AmazonTranscribeConfig implements Serializable {
 
@@ -32,6 +32,7 @@ public class AmazonTranscribeConfig implements Serializable {
         return clientId;
     }
 
+    @OperatorOnly
     public void setClientId(String clientId) throws TikaConfigException {
         this.clientId = clientId;
     }
@@ -40,6 +41,7 @@ public class AmazonTranscribeConfig implements Serializable {
         return clientSecret;
     }
 
+    @OperatorOnly
     public void setClientSecret(String clientSecret) throws 
TikaConfigException {
         this.clientSecret = clientSecret;
     }
@@ -48,6 +50,7 @@ public class AmazonTranscribeConfig implements Serializable {
         return bucketName;
     }
 
+    @OperatorOnly
     public void setBucket(String bucketName) throws TikaConfigException {
         this.bucketName = bucketName;
     }
@@ -56,60 +59,8 @@ public class AmazonTranscribeConfig implements Serializable {
         return region;
     }
 
+    @OperatorOnly
     public void setRegion(String region) throws TikaConfigException {
         this.region = region;
     }
-
-    /**
-     * RuntimeConfig blocks modification of security-sensitive credential and
-     * infrastructure fields at runtime. When a config is obtained from 
ParseContext
-     * (i.e. user-provided at parse time), it should be deserialized as a 
RuntimeConfig
-     * to prevent credential/infrastructure injection.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any 
credential fields, the
-     * overridden setters will throw TikaConfigException.
-     */
-    public static class RuntimeConfig extends AmazonTranscribeConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setClientId(String clientId) throws TikaConfigException {
-            if (!StringUtils.isBlank(clientId)) {
-                throw new TikaConfigException(
-                        "Cannot modify clientId at runtime. " +
-                                "Credentials must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setClientSecret(String clientSecret) throws 
TikaConfigException {
-            if (!StringUtils.isBlank(clientSecret)) {
-                throw new TikaConfigException(
-                        "Cannot modify clientSecret at runtime. " +
-                                "Credentials must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setBucket(String bucketName) throws TikaConfigException {
-            if (!StringUtils.isBlank(bucketName)) {
-                throw new TikaConfigException(
-                        "Cannot modify bucketName at runtime. " +
-                                "Infrastructure must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setRegion(String region) throws TikaConfigException {
-            if (!StringUtils.isBlank(region)) {
-                throw new TikaConfigException(
-                        "Cannot modify region at runtime. " +
-                                "Infrastructure must be configured at parser 
initialization time.");
-            }
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
index c9e4fd4ad2..8467369f75 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
@@ -24,6 +24,7 @@ import java.io.InputStream;
 import java.util.Base64;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 import org.slf4j.Logger;
@@ -288,16 +289,12 @@ public abstract class AbstractVLMParser implements 
Parser, Initializable, Closea
             throws TikaConfigException, IOException {
         String key = configKey();
         if (parseContext.hasJsonConfig(key)) {
-            VLMOCRConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                    parseContext, key, VLMOCRConfig.RuntimeConfig.class,
-                    new VLMOCRConfig.RuntimeConfig(defaultConfig));
-
-            if (runtimeConfig.isSkipOcr()) {
-                return runtimeConfig;
-            }
-
-            return ParseContextConfig.getConfig(
+            VLMOCRConfig config = ParseContextConfig.getConfig(
                     parseContext, key, VLMOCRConfig.class, defaultConfig);
+            if (!parseContext.getJsonConfig(key).trusted()) {
+                checkRuntimeOverrides(config);
+            }
+            return config;
         }
         VLMOCRConfig userConfig = parseContext.get(VLMOCRConfig.class);
         if (userConfig != null) {
@@ -306,6 +303,32 @@ public abstract class AbstractVLMParser implements Parser, 
Initializable, Closea
         return defaultConfig;
     }
 
+    /**
+     * The per-request rules {@code @OperatorOnly} cannot express: maxTokens 
may be lowered
+     * but never raised (the ceiling bounds cost on a paid endpoint), and the 
prompt, with
+     * textRecognizer which describes it, changes only when the operator 
allowed that.
+     */
+    private void checkRuntimeOverrides(VLMOCRConfig config) throws 
TikaConfigException {
+        if (config.getMaxTokens() > defaultConfig.getMaxTokens()) {
+            throw new TikaConfigException("Cannot increase maxTokens beyond 
the init-time value ("
+                    + defaultConfig.getMaxTokens() + ") at runtime. Requested: 
"
+                    + config.getMaxTokens());
+        }
+        if (defaultConfig.isAllowRuntimePrompt()) {
+            return;
+        }
+        if (!Objects.equals(config.getPrompt(), defaultConfig.getPrompt())) {
+            throw new TikaConfigException("Cannot modify prompt at runtime. 
Set "
+                    + "allowRuntimePrompt=true at initialization time to 
permit per-request "
+                    + "prompt overrides.");
+        }
+        if (config.isTextRecognizer() != defaultConfig.isTextRecognizer()) {
+            throw new TikaConfigException("Cannot modify textRecognizer at 
runtime. Set "
+                    + "allowRuntimePrompt=true at initialization time to 
permit per-request "
+                    + "prompt overrides.");
+        }
+    }
+
     protected static String stripTrailingSlash(String url) {
         return url.replaceAll("/+$", "");
     }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
index 22b716be88..79ea76b38a 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/VLMOCRConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.vlm;
 
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
 
 /**
  * Configuration for {@link AbstractVLMParser} and its subclasses.
@@ -122,6 +122,7 @@ public class VLMOCRConfig implements Serializable {
         return baseUrl;
     }
 
+    @OperatorOnly
     public void setBaseUrl(String baseUrl) throws TikaConfigException {
         this.baseUrl = baseUrl;
     }
@@ -130,6 +131,7 @@ public class VLMOCRConfig implements Serializable {
         return completionsPath;
     }
 
+    @OperatorOnly
     public void setCompletionsPath(String completionsPath) {
         this.completionsPath = completionsPath;
     }
@@ -138,6 +140,7 @@ public class VLMOCRConfig implements Serializable {
         return model;
     }
 
+    @OperatorOnly
     public void setModel(String model) {
         this.model = model;
     }
@@ -170,6 +173,7 @@ public class VLMOCRConfig implements Serializable {
         return maxRetries;
     }
 
+    @OperatorOnly
     public void setMaxRetries(int maxRetries) {
         this.maxRetries = maxRetries;
     }
@@ -178,6 +182,7 @@ public class VLMOCRConfig implements Serializable {
         return apiKey;
     }
 
+    @OperatorOnly
     public void setApiKey(String apiKey) throws TikaConfigException {
         this.apiKey = apiKey;
     }
@@ -230,6 +235,7 @@ public class VLMOCRConfig implements Serializable {
      * Set the maximum total pixels (width &times; height) for an image.
      * Default is 100,000,000. Set to {@code -1} for no limit (not 
recommended).
      */
+    @OperatorOnly
     public void setMaxImagePixels(long maxImagePixels) {
         if (maxImagePixels < 1 && maxImagePixels != -1) {
             throw new IllegalArgumentException(
@@ -243,126 +249,8 @@ public class VLMOCRConfig implements Serializable {
         return allowRuntimePrompt;
     }
 
+    @OperatorOnly
     public void setAllowRuntimePrompt(boolean allowRuntimePrompt) {
         this.allowRuntimePrompt = allowRuntimePrompt;
     }
-
-    /**
-     * Runtime-only config that prevents modification of security-sensitive
-     * and cost-sensitive fields at parse time.
-     * <p>
-     * <b>Always blocked:</b> {@code baseUrl}, {@code apiKey}, {@code model},
-     * {@code maxTokens}, {@code maxRetries}, {@code allowRuntimePrompt}.
-     * <p>
-     * <b>Blocked by default (opt-in):</b> {@code prompt} — set
-     * {@code allowRuntimePrompt=true} at initialization time to permit
-     * per-request prompt overrides.
-     * <p>
-     * The {@code model} field is unconditionally blocked because there is
-     * no legitimate reason to swap models per-request; if a different model
-     * is needed, configure a separate parser instance.
-     * <p>
-     * The {@code maxTokens} field cannot be raised above the init-time
-     * value at runtime to prevent cost attacks on paid API endpoints.
-     */
-    public static class RuntimeConfig extends VLMOCRConfig {
-
-        /** Init-time maxTokens ceiling — runtime requests cannot exceed this. 
*/
-        private int initMaxTokens = 4096;
-
-        public RuntimeConfig() {
-        }
-
-        /**
-         * Creates a RuntimeConfig that inherits the init-time
-         * {@code allowRuntimePrompt} setting and the {@code maxTokens}
-         * ceiling from the given parent config.
-         */
-        public RuntimeConfig(VLMOCRConfig initConfig) {
-            super.setAllowRuntimePrompt(initConfig.isAllowRuntimePrompt());
-            this.initMaxTokens = initConfig.getMaxTokens();
-        }
-
-        @Override
-        public void setBaseUrl(String baseUrl) throws TikaConfigException {
-            if (!StringUtils.isBlank(baseUrl)) {
-                throw new TikaConfigException(
-                        "Cannot modify baseUrl at runtime. "
-                                + "URLs must be configured at initialization 
time.");
-            }
-        }
-
-        @Override
-        public void setApiKey(String apiKey) throws TikaConfigException {
-            if (!StringUtils.isBlank(apiKey)) {
-                throw new TikaConfigException(
-                        "Cannot modify apiKey at runtime. "
-                                + "API keys must be configured at 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setModel(String model) {
-            throw new IllegalStateException(
-                    "Cannot modify model at runtime. "
-                            + "Models must be configured at initialization 
time. "
-                            + "If you need a different model, configure a "
-                            + "separate parser instance.");
-        }
-
-        @Override
-        public void setMaxRetries(int maxRetries) {
-            throw new IllegalStateException("Cannot modify maxRetries at 
runtime. "
-                    + "Retries are configured at initialization time.");
-        }
-
-        @Override
-        public void setPrompt(String prompt) {
-            if (!isAllowRuntimePrompt()) {
-                throw new IllegalStateException(
-                        "Cannot modify prompt at runtime. "
-                                + "Set allowRuntimePrompt=true at 
initialization time "
-                                + "to permit per-request prompt overrides.");
-            }
-            super.setPrompt(prompt);
-        }
-
-        // describes the prompt, so it is locked with it
-        @Override
-        public void setTextRecognizer(boolean textRecognizer) {
-            if (!isAllowRuntimePrompt()) {
-                throw new IllegalStateException(
-                        "Cannot modify textRecognizer at runtime. "
-                                + "Set allowRuntimePrompt=true at 
initialization time "
-                                + "to permit per-request prompt overrides.");
-            }
-            super.setTextRecognizer(textRecognizer);
-        }
-
-        @Override
-        public void setMaxTokens(int maxTokens) {
-            if (maxTokens > initMaxTokens) {
-                throw new IllegalStateException(
-                        "Cannot increase maxTokens beyond the init-time value 
("
-                                + initMaxTokens + ") at runtime. "
-                                + "Requested: " + maxTokens);
-            }
-            super.setMaxTokens(maxTokens);
-        }
-
-        @Override
-        public void setAllowRuntimePrompt(boolean allowRuntimePrompt) {
-            throw new IllegalStateException(
-                    "Cannot modify allowRuntimePrompt at runtime. "
-                            + "This must be configured at initialization 
time.");
-        }
-
-        @Override
-        public void setMaxImagePixels(long maxImagePixels) {
-            throw new IllegalStateException(
-                    "Cannot modify maxImagePixels at runtime. "
-                            + "Image size limits must be configured at "
-                            + "initialization time.");
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
new file mode 100644
index 0000000000..d355df5d81
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMOperatorOnlyConfigTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.vlm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Per-request rules as the parser applies them: the {@code @OperatorOnly} 
fields are refused
+ * outright, maxTokens may only be lowered, and the prompt (with 
textRecognizer) changes only
+ * when the operator allowed it.
+ */
+public class VLMOperatorOnlyConfigTest {
+
+    private static final String KEY = "openai-vlm-parser";
+
+    private VLMOCRConfig resolve(String json, boolean trusted, VLMOCRConfig 
init) throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig(KEY, json, trusted);
+        return new OpenAIVLMParser(init).getConfig(context);
+    }
+
+    private VLMOCRConfig perRequest(String json) throws Exception {
+        return resolve(json, false, new VLMOCRConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new VLMOCRConfig().getBaseUrl(), 
perRequest("{}").getBaseUrl());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(perRequest("{\"skipOcr\": true}").isSkipOcr());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"model\": \"evil\"}", "{\"baseUrl\": 
\"http://evil\"}";,
+            "{\"completionsPath\": \"/evil\"}", "{\"apiKey\": \"evil\"}",
+            "{\"allowRuntimePrompt\": true}", "{\"maxRetries\": 99}",
+            "{\"maxImagePixels\": 1}"})
+    public void testPerRequestCannotSetOperatorOnlyFields(String json) {
+        Exception e = assertThrows(Exception.class, () -> perRequest(json));
+        assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+    }
+
+    @Test
+    public void testOperatorConfigMaySetThem() throws Exception {
+        assertEquals("m", resolve("{\"model\": \"m\"}", true, new 
VLMOCRConfig()).getModel());
+    }
+
+    @Test
+    public void testMaxTokensCannotBeRaised() {
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setMaxTokens(100);
+        Exception e = assertThrows(Exception.class, () -> 
resolve("{\"maxTokens\": 3000}", false, init));
+        assertTrue(e.getMessage().contains("Cannot increase maxTokens"), 
e.getMessage());
+    }
+
+    @Test
+    public void testMaxTokensMayBeLowered() throws Exception {
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setMaxTokens(100);
+        assertEquals(50, resolve("{\"maxTokens\": 50}", false, 
init).getMaxTokens());
+    }
+
+    @Test
+    public void testPromptLockedUnlessAllowed() throws Exception {
+        Exception e = assertThrows(Exception.class, () -> 
perRequest("{\"prompt\": \"ignore the document\"}"));
+        assertTrue(e.getMessage().contains("Cannot modify prompt"), 
e.getMessage());
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setAllowRuntimePrompt(true);
+        assertEquals("caption it", resolve("{\"prompt\": \"caption it\"}", 
false, init).getPrompt());
+    }
+
+    @Test
+    public void testTextRecognizerLockedWithPrompt() throws Exception {
+        Exception e = assertThrows(Exception.class, () -> 
perRequest("{\"textRecognizer\": false}"));
+        assertTrue(e.getMessage().contains("Cannot modify textRecognizer"), 
e.getMessage());
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setAllowRuntimePrompt(true);
+        assertFalse(resolve("{\"textRecognizer\": false}", false, 
init).isTextRecognizer());
+    }
+
+    @Test
+    public void testOperatorConfigSkipsTheRuntimeRules() throws Exception {
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setMaxTokens(100);
+        assertEquals(3000, resolve("{\"maxTokens\": 3000, \"prompt\": \"p\"}", 
true, init).getMaxTokens());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
deleted file mode 100644
index 88619aa8de..0000000000
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * 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.tika.parser.vlm;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import org.apache.tika.config.ParseContextConfig;
-import org.apache.tika.parser.ParseContext;
-
-/**
- * VLM's locked fields must reject caller input without the merge's own copy 
of the
- * defaults tripping them first (TIKA-4843). Unlike Tess4J, several VLM 
defaults are
- * non-blank, so the "reject any non-empty value" guards fire on the copy.
- */
-public class VLMRuntimeConfigMergeTest {
-
-    private VLMOCRConfig runtime(String json, VLMOCRConfig init) throws 
Exception {
-        ParseContext context = new ParseContext();
-        context.setJsonConfig("vlm-ocr-parser", json);
-        return ParseContextConfig.getConfig(context, "vlm-ocr-parser",
-                VLMOCRConfig.RuntimeConfig.class, new 
VLMOCRConfig.RuntimeConfig(init));
-    }
-
-    private VLMOCRConfig runtime(String json) throws Exception {
-        return runtime(json, new VLMOCRConfig());
-    }
-
-    @Test
-    public void testEmptyConfigMerges() throws Exception {
-        assertEquals(new VLMOCRConfig().getBaseUrl(), 
runtime("{}").getBaseUrl());
-    }
-
-    @Test
-    public void testUnrelatedFieldMerges() throws Exception {
-        assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
-    }
-
-    @Test
-    public void testCallerSetModelStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> runtime("{\"model\": 
\"evil\"}"));
-        assertTrue(rootMessage(e).contains("Cannot modify model"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetBaseUrlStillRejected() {
-        Exception e = assertThrows(Exception.class,
-                () -> runtime("{\"baseUrl\": \"http://evil\"}";));
-        assertTrue(rootMessage(e).contains("Cannot modify baseUrl"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetApiKeyStillRejected() {
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"apiKey\": \"evil\"}"));
-        assertTrue(rootMessage(e).contains("Cannot modify apiKey"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testCallerSetAllowRuntimePromptStillRejected() {
-        Exception e = assertThrows(Exception.class,
-                () -> runtime("{\"allowRuntimePrompt\": true}"));
-        assertTrue(rootMessage(e).contains("Cannot modify 
allowRuntimePrompt"), rootMessage(e));
-    }
-
-    /**
-     * The maxTokens ceiling is init-time state with no getter. If the merge's 
copy resets it
-     * to the class default, a caller can raise maxTokens above what the 
operator configured.
-     */
-    @Test
-    public void testMaxTokensCeilingIsNotWidenedByTheCopy() {
-        VLMOCRConfig init = new VLMOCRConfig();
-        init.setMaxTokens(100);
-        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"maxTokens\": 3000}", init));
-        assertTrue(rootMessage(e).contains("Cannot increase maxTokens"), 
rootMessage(e));
-    }
-
-    @Test
-    public void testMaxTokensBelowCeilingStillAllowed() throws Exception {
-        VLMOCRConfig init = new VLMOCRConfig();
-        init.setMaxTokens(100);
-        assertEquals(50, runtime("{\"maxTokens\": 50}", init).getMaxTokens());
-    }
-
-    private static String rootMessage(Throwable t) {
-        while (t.getCause() != null) {
-            t = t.getCause();
-        }
-        return String.valueOf(t.getMessage());
-    }
-
-    @Test
-    public void testTextRecognizerLockedWithPrompt() throws Exception {
-        Exception e = assertThrows(Exception.class,
-                () -> runtime("{\"textRecognizer\": false}"));
-        assertTrue(rootMessage(e).contains("Cannot modify textRecognizer"), 
rootMessage(e));
-        VLMOCRConfig init = new VLMOCRConfig();
-        init.setAllowRuntimePrompt(true);
-        assertFalse(runtime("{\"textRecognizer\": false}", 
init).isTextRecognizer());
-    }
-}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
index f3ee0a83a5..d3ced1af27 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/ocr/TesseractOCRParserTest.java
@@ -308,9 +308,9 @@ public class TesseractOCRParserTest extends TikaTest {
         otherConfig.put("k2", "b2");
         runtimeUpdates.put("otherTesseractConfig", otherConfig);
 
-        // Store runtime config in ParseContext
+        // Operator JSON (a preset): otherTesseractConfig is refused in 
per-request JSON
         ParseContext context = new ParseContext();
-        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(runtimeUpdates));
+        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(runtimeUpdates), true);
 
         // Merge configs using ParseContextConfig
         TesseractOCRConfig mergedConfig = ParseContextConfig.getConfig(
@@ -331,70 +331,38 @@ public class TesseractOCRParserTest extends TikaTest {
         assertEquals("a2", defaultConfig.getOtherTesseractConfig().get("k2"));
     }
 
+    /**
+     * Paths name binaries the parser runs and otherTesseractConfig names 
files the binary
+     * opens, so per-request JSON may not set them; operator JSON may.
+     */
     @Test
-    public void testRuntimeConfigPathValidation() throws Exception {
-        ObjectMapper mapper = new ObjectMapper();
-
-        // Test that setting tesseractPath at runtime throws exception
-        Map<String, Object> configWithTesseractPath = new HashMap<>();
-        configWithTesseractPath.put("tesseractPath", "/some/path");
+    public void testPerRequestConfigCannotSetOperatorOnlyFields() throws 
Exception {
+        for (String json : new String[]{
+                "{\"tesseractPath\": \"/some/path\"}",
+                "{\"tessdataPath\": \"/some/path\"}",
+                "{\"imageMagickPath\": \"/some/path\"}",
+                "{\"trustedPageSeparator\": \"<p/>\"}",
+                "{\"otherTesseractConfig\": {\"debug_file\": 
\"/tmp/anywhere\"}}"}) {
+            ParseContext context = new ParseContext();
+            context.setJsonConfig("tesseract-ocr-parser", json);
+            IOException e = assertThrows(IOException.class, () -> 
ParseContextConfig.getConfig(
+                    context, "tesseract-ocr-parser", TesseractOCRConfig.class, 
new TesseractOCRConfig()),
+                    json);
+            assertTrue(e.getMessage().contains("at runtime"), e.getMessage());
+        }
 
         ParseContext context = new ParseContext();
-        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(configWithTesseractPath));
-
-        IOException exception = assertThrows(IOException.class, () -> {
-            ParseContextConfig.getConfig(
-                    context,
-                    "tesseract-ocr-parser",
-                    TesseractOCRConfig.RuntimeConfig.class,
-                    new TesseractOCRConfig.RuntimeConfig());
-        });
-        assertTrue(exception.getMessage().contains("Cannot modify 
tesseractPath at runtime"));
-
-        // Test that setting tessdataPath at runtime throws exception
-        Map<String, Object> configWithTessdataPath = new HashMap<>();
-        configWithTessdataPath.put("tessdataPath", "/some/path");
-
-        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(configWithTessdataPath));
-
-        exception = assertThrows(IOException.class, () -> {
-            ParseContextConfig.getConfig(
-                    context,
-                    "tesseract-ocr-parser",
-                    TesseractOCRConfig.RuntimeConfig.class,
-                    new TesseractOCRConfig.RuntimeConfig());
-        });
-        assertTrue(exception.getMessage().contains("Cannot modify tessdataPath 
at runtime"));
-
-        // Test that setting imageMagickPath at runtime throws exception
-        Map<String, Object> configWithImageMagickPath = new HashMap<>();
-        configWithImageMagickPath.put("imageMagickPath", "/some/path");
-
-        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(configWithImageMagickPath));
-
-        exception = assertThrows(IOException.class, () -> {
-            ParseContextConfig.getConfig(
-                    context,
-                    "tesseract-ocr-parser",
-                    TesseractOCRConfig.RuntimeConfig.class,
-                    new TesseractOCRConfig.RuntimeConfig());
-        });
-        assertTrue(exception.getMessage().contains("Cannot modify 
imageMagickPath at runtime"));
-
-        // Test that setting non-path fields works fine
-        Map<String, Object> validRuntimeConfig = new HashMap<>();
-        validRuntimeConfig.put("language", "fra");
-        validRuntimeConfig.put("skipOcr", true);
-
-        context.setJsonConfig("tesseract-ocr-parser", 
mapper.writeValueAsString(validRuntimeConfig));
-
-        // This should not throw
-        TesseractOCRConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                context,
-                "tesseract-ocr-parser",
-                TesseractOCRConfig.RuntimeConfig.class,
-                new TesseractOCRConfig.RuntimeConfig());
-        assertEquals("fra", runtimeConfig.getLanguage());
-        assertTrue(runtimeConfig.isSkipOcr());
+        context.setJsonConfig("tesseract-ocr-parser", "{\"language\": \"fra\", 
\"skipOcr\": true}");
+        TesseractOCRConfig perRequest = ParseContextConfig.getConfig(
+                context, "tesseract-ocr-parser", TesseractOCRConfig.class, new 
TesseractOCRConfig());
+        assertEquals("fra", perRequest.getLanguage());
+        assertTrue(perRequest.isSkipOcr());
+
+        context = new ParseContext();
+        context.setJsonConfig("tesseract-ocr-parser",
+                "{\"otherTesseractConfig\": {\"tessedit_char_whitelist\": 
\"0123456789\"}}", true);
+        TesseractOCRConfig operator = ParseContextConfig.getConfig(
+                context, "tesseract-ocr-parser", TesseractOCRConfig.class, new 
TesseractOCRConfig());
+        assertEquals("0123456789", 
operator.getOtherTesseractConfig().get("tessedit_char_whitelist"));
     }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
index 423a45147f..4a6e4755ba 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-cad-module/src/main/java/org/apache/tika/parser/dwg/DWGParserConfig.java
@@ -27,6 +27,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.tika.config.Initializable;
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.utils.ProcessUtils;
 import org.apache.tika.utils.StringUtils;
@@ -92,6 +93,7 @@ public class DWGParserConfig implements Serializable, 
Initializable {
         return cleanDwgReadReplaceWith;
     }
 
+    @OperatorOnly
     public void setDwgReadExecutable(String dwgReadExecutable) {
         if (!Paths.get(dwgReadExecutable).isAbsolute())
             try {
@@ -123,29 +125,4 @@ public class DWGParserConfig implements Serializable, 
Initializable {
     public void setCleanDwgReadReplaceWith(String cleanDwgReadReplaceWith) {
         this.cleanDwgReadReplaceWith = cleanDwgReadReplaceWith;
     }
-
-    /**
-     * RuntimeConfig blocks modification of security-sensitive path fields at 
runtime.
-     * When a config is obtained from ParseContext (i.e. user-provided at 
parse time),
-     * it should be deserialized as a RuntimeConfig to prevent path injection.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any path 
fields, the
-     * overridden setters will throw TikaConfigException.
-     */
-    public static class RuntimeConfig extends DWGParserConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setDwgReadExecutable(String dwgReadExecutable) {
-            if (!StringUtils.isBlank(dwgReadExecutable)) {
-                throw new IllegalArgumentException(
-                        "Cannot modify dwgReadExecutable at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
index c59be32c9f..044a8a5836 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/libpst/LibPstParserConfig.java
@@ -18,8 +18,8 @@ package org.apache.tika.parser.microsoft.libpst;
 
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
 
 public class LibPstParserConfig implements Serializable {
 
@@ -94,32 +94,8 @@ public class LibPstParserConfig implements Serializable {
         return readPstPath;
     }
 
+    @OperatorOnly
     public void setReadPstPath(String readPstPath) throws TikaConfigException {
         this.readPstPath = readPstPath;
     }
-
-    /**
-     * RuntimeConfig blocks modification of security-sensitive path fields at 
runtime.
-     * When a config is obtained from ParseContext (i.e. user-provided at 
parse time),
-     * it should be deserialized as a RuntimeConfig to prevent path injection.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any path 
fields, the
-     * overridden setters will throw TikaConfigException.
-     */
-    public static class RuntimeConfig extends LibPstParserConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setReadPstPath(String readPstPath) throws 
TikaConfigException {
-            if (!StringUtils.isBlank(readPstPath)) {
-                throw new TikaConfigException(
-                        "Cannot modify readPstPath at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
index ca5cb4d6ea..6d7c51e060 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRConfig.java
@@ -30,6 +30,7 @@ import org.apache.commons.io.FilenameUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.utils.StringUtils;
 
@@ -213,6 +214,7 @@ public class TesseractOCRConfig implements Serializable {
      *
      * @param pageSeparator
      */
+    @OperatorOnly
     public void setTrustedPageSeparator(String pageSeparator) {
         this.pageSeparator = pageSeparator;
     }
@@ -504,6 +506,7 @@ public class TesseractOCRConfig implements Serializable {
      *
      * @param otherTesseractConfig map of key-value pairs
      */
+    @OperatorOnly
     public void setOtherTesseractConfig(Map<String, String> 
otherTesseractConfig) {
         if (otherTesseractConfig != null) {
             for (Map.Entry<String, String> entry : 
otherTesseractConfig.entrySet()) {
@@ -542,6 +545,7 @@ public class TesseractOCRConfig implements Serializable {
         return tesseractPath;
     }
 
+    @OperatorOnly
     public void setTesseractPath(String tesseractPath) throws 
TikaConfigException {
         tesseractPath = FilenameUtils.normalize(tesseractPath);
         if (!tesseractPath.isEmpty() && 
!tesseractPath.endsWith(File.separator)) {
@@ -554,6 +558,7 @@ public class TesseractOCRConfig implements Serializable {
         return tessdataPath;
     }
 
+    @OperatorOnly
     public void setTessdataPath(String tessdataPath) throws 
TikaConfigException {
         tessdataPath = FilenameUtils.normalize(tessdataPath);
         if (!tessdataPath.isEmpty() && !tessdataPath.endsWith(File.separator)) 
{
@@ -566,6 +571,7 @@ public class TesseractOCRConfig implements Serializable {
         return imageMagickPath;
     }
 
+    @OperatorOnly
     public void setImageMagickPath(String imageMagickPath) throws 
TikaConfigException {
         imageMagickPath = FilenameUtils.normalize(imageMagickPath);
         if (!imageMagickPath.isEmpty() && 
!imageMagickPath.endsWith(File.separator)) {
@@ -574,56 +580,6 @@ public class TesseractOCRConfig implements Serializable {
         this.imageMagickPath = imageMagickPath;
     }
 
-    /**
-     * Runtime-only TesseractOCRConfig that prevents modification of paths and 
of the
-     * {@code -c} pass-through map. Used to enforce immutability of 
parser-level paths
-     * during parse-time configuration.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any path 
fields, the
-     * overridden setters will throw TikaConfigException. {@code 
otherTesseractConfig} is
-     * refused as well: tesseract variables such as {@code debug_file} name 
files the
-     * binary opens, so the map is operator configuration only.
-     */
-    public static class RuntimeConfig extends TesseractOCRConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setTesseractPath(String tesseractPath) throws 
TikaConfigException {
-            if (! StringUtils.isBlank(tesseractPath)) {
-                throw new TikaConfigException("Cannot modify tesseractPath at 
runtime. Paths must be configured at parser initialization time.");
-            }
-        }
-
-        @Override
-        public void setTessdataPath(String tessdataPath) throws 
TikaConfigException {
-            if (! StringUtils.isBlank(tessdataPath)) {
-                throw new TikaConfigException("Cannot modify tessdataPath at 
runtime. " + "Paths must be configured at parser initialization time.");
-            }
-        }
-
-        @Override
-        public void setImageMagickPath(String imageMagickPath) throws 
TikaConfigException {
-            if (! StringUtils.isBlank(imageMagickPath)) {
-                throw new TikaConfigException("Cannot modify imageMagickPath 
at runtime. " + "Paths must be configured at parser initialization time.");
-            }
-        }
-
-        @Override
-        public void setTrustedPageSeparator(String pageSeparator) {
-            throw new IllegalArgumentException("Cannot use 
setTrustedPageSeparator at runtime. " + "Use setPageSeparator instead.");
-        }
-
-        @Override
-        public void addOtherTesseractConfig(String key, String value) {
-            throw new IllegalArgumentException("Cannot set 
otherTesseractConfig at runtime. " +
-                    "Tesseract variables must be configured at parser 
initialization time.");
-        }
-    }
-
     public enum OUTPUT_TYPE {
         TXT, HOCR
     }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
index 1a8bee22e9..c5855b0cbf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
@@ -306,18 +306,7 @@ public class TesseractOCRParser extends 
AbstractExternalProcessParser
 
     TesseractOCRConfig getConfig(ParseContext parseContext) throws 
TikaConfigException, IOException {
         if (parseContext.hasJsonConfig("tesseract-ocr-parser")) {
-            // per-request JSON is screened by RuntimeConfig (no paths, no 
otherTesseractConfig);
-            // operator JSON (config file, presets) is not
-            if (!parseContext.getJsonConfig("tesseract-ocr-parser").trusted()) 
{
-                TesseractOCRConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
-                        parseContext,
-                        "tesseract-ocr-parser",
-                        TesseractOCRConfig.RuntimeConfig.class,
-                        new TesseractOCRConfig.RuntimeConfig());
-                if (runtimeConfig.isSkipOcr()) {
-                    return runtimeConfig;
-                }
-            }
+            // per-request JSON may not set the @OperatorOnly paths or 
otherTesseractConfig
             return ParseContextConfig.getConfig(
                     parseContext,
                     "tesseract-ocr-parser",
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
index fbabb4b552..cb511a749c 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/test/java/org/apache/tika/parser/ocr/TesseractOCRConfigTest.java
@@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.fail;
 
 import java.util.Arrays;
 import java.util.List;
-import java.util.Map;
 
 import org.junit.jupiter.api.Test;
 
@@ -234,21 +233,6 @@ public class TesseractOCRConfigTest extends TikaTest {
         config.addOtherTesseractConfig("good", "good");
     }
 
-    @Test
-    public void testRuntimeConfigRefusesOtherParameters() {
-        TesseractOCRConfig.RuntimeConfig config = new 
TesseractOCRConfig.RuntimeConfig();
-        assertThrows(IllegalArgumentException.class, () -> {
-            config.addOtherTesseractConfig("debug_file", "/tmp/anywhere");
-        });
-        assertThrows(IllegalArgumentException.class, () -> {
-            config.setOtherTesseractConfig(Map.of("tessedit_char_whitelist", 
"0123456789"));
-        });
-        // nothing to refuse
-        config.setOtherTesseractConfig(Map.of());
-        config.setOtherTesseractConfig(null);
-        assertTrue(config.getOtherTesseractConfig().isEmpty());
-    }
-
     @Test
     public void testBadLanguageCode() throws Exception {
         TesseractOCRConfig tesseractOCRConfig = new TesseractOCRConfig();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index 7738d085d6..4e947b62ba 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -272,16 +272,6 @@ public class PDFParser implements Parser, RenderingParser, 
EnrichingParser {
     }
 
     private PDFParserConfig getConfig(ParseContext parseContext) throws 
TikaException, IOException {
-        if (parseContext.hasJsonConfig("pdf-parser")
-                && !parseContext.getJsonConfig("pdf-parser").trusted()) {
-            // resolved for its guards only: per-request JSON may not name an 
image graphics
-            // engine factory, operator JSON (config file, presets) still may
-            ParseContextConfig.getConfig(
-                    parseContext,
-                    "pdf-parser",
-                    PDFParserConfig.RuntimeConfig.class,
-                    new PDFParserConfig.RuntimeConfig());
-        }
         PDFParserConfig config = ParseContextConfig.getConfig(
                 parseContext,
                 "pdf-parser",
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
index efe0c740ec..ef5bf21fc7 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
@@ -20,9 +20,9 @@ import java.io.Serializable;
 
 import org.apache.pdfbox.text.PDFTextStripper;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.parser.pages.PagesConfig;
 import org.apache.tika.parser.pdf.image.ImageGraphicsEngineFactory;
-import org.apache.tika.utils.StringUtils;
 
 /**
  * Config for PDFParser.
@@ -658,6 +658,7 @@ public class PDFParserConfig implements Serializable {
      *
      * @param imageGraphicsEngineFactory
      */
+    @OperatorOnly
     public void setImageGraphicsEngineFactory(ImageGraphicsEngineFactory 
imageGraphicsEngineFactory) {
         this.imageGraphicsEngineFactory = imageGraphicsEngineFactory;
     }
@@ -668,6 +669,7 @@ public class PDFParserConfig implements Serializable {
      *
      * @param className fully qualified class name of an 
ImageGraphicsEngineFactory implementation
      */
+    @OperatorOnly
     public void setImageGraphicsEngineFactoryClass(String className) {
         try {
             Class<?> clazz = Class.forName(className);
@@ -741,39 +743,6 @@ public class PDFParserConfig implements Serializable {
         return throwOnEncryptedPayload;
     }
 
-    /**
-     * Runtime-only PDFParserConfig that refuses the image graphics engine 
factory, which
-     * {@link PDFParserConfig#setImageGraphicsEngineFactoryClass(String)} 
loads and
-     * instantiates by name.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization), which populates
-     * fields via setters, so a per-request {@code pdf-parser} block naming a 
factory throws
-     * here. The factory is operator configuration.
-     */
-    public static class RuntimeConfig extends PDFParserConfig {
-
-        private static final long serialVersionUID = 6492570218190936987L;
-
-        @Override
-        public void setImageGraphicsEngineFactoryClass(String className) {
-            if (!StringUtils.isBlank(className)) {
-                throw new IllegalArgumentException("Cannot modify 
imageGraphicsEngineFactoryClass"
-                        + " at runtime. The image graphics engine factory must 
be configured at"
-                        + " parser initialization time.");
-            }
-        }
-
-        @Override
-        public void setImageGraphicsEngineFactory(
-                ImageGraphicsEngineFactory imageGraphicsEngineFactory) {
-            if (imageGraphicsEngineFactory != null) {
-                throw new IllegalArgumentException("Cannot modify 
imageGraphicsEngineFactory at"
-                        + " runtime. The image graphics engine factory must be 
configured at"
-                        + " parser initialization time.");
-            }
-        }
-    }
-
     /** @deprecated since 4.1.0; see {@link #setImageStrategy}. */
     @Deprecated
     public enum IMAGE_STRATEGY {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
index d2620d960c..12d073ada6 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-text-module/src/main/java/org/apache/tika/parser/strings/StringsConfig.java
@@ -19,8 +19,8 @@ package org.apache.tika.parser.strings;
 import java.io.File;
 import java.io.Serializable;
 
+import org.apache.tika.config.OperatorOnly;
 import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.utils.StringUtils;
 
 /**
  * Configuration for the "strings" (or strings-alternative) command.
@@ -58,6 +58,7 @@ public class StringsConfig implements Serializable {
      *
      * @param stringsPath the "strings" installation folder.
      */
+    @OperatorOnly
     public void setStringsPath(String stringsPath) throws TikaConfigException {
         if (stringsPath != null && !stringsPath.isEmpty() &&
                 !stringsPath.endsWith(File.separator)) {
@@ -80,6 +81,7 @@ public class StringsConfig implements Serializable {
      *
      * @param filePath the path to the "file" command.
      */
+    @OperatorOnly
     public void setFilePath(String filePath) throws TikaConfigException {
         this.filePath = filePath;
     }
@@ -149,38 +151,4 @@ public class StringsConfig implements Serializable {
         }
         this.timeoutMillis = timeoutMillis;
     }
-
-    /**
-     * RuntimeConfig blocks modification of security-sensitive path fields at 
runtime.
-     * When a config is obtained from ParseContext (i.e. user-provided at 
parse time),
-     * it should be deserialized as a RuntimeConfig to prevent path injection.
-     * <p>
-     * This class is deserialized by ConfigDeserializer (in 
tika-serialization) which uses
-     * Jackson to populate fields via setters. If the JSON contains any path 
fields, the
-     * overridden setters will throw TikaConfigException.
-     */
-    public static class RuntimeConfig extends StringsConfig {
-
-        public RuntimeConfig() {
-            super();
-        }
-
-        @Override
-        public void setStringsPath(String stringsPath) throws 
TikaConfigException {
-            if (!StringUtils.isBlank(stringsPath)) {
-                throw new TikaConfigException(
-                        "Cannot modify stringsPath at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-
-        @Override
-        public void setFilePath(String filePath) throws TikaConfigException {
-            if (!StringUtils.isBlank(filePath)) {
-                throw new TikaConfigException(
-                        "Cannot modify filePath at runtime. " +
-                                "Paths must be configured at parser 
initialization time.");
-            }
-        }
-    }
 }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
index 5f99bccc76..752eff1fbf 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
@@ -43,12 +43,10 @@ public final class JsonMergeUtils {
      * mapper they were derived from ({@code copy()} is expensive and the set 
of source
      * mappers is tiny and long-lived).
      * <p>
-     * The clone must not run through setters. Runtime-config subclasses 
override their
-     * setters to reject caller input -- often as "any non-empty value is a 
modification"
-     * -- so re-applying the default's own values through them throws, and the 
caller's
-     * JSON is never even reached. Copying by field also preserves init-time 
state that
-     * has no getter (e.g. VLMOCRConfig.RuntimeConfig's initMaxTokens 
baseline), which a
-     * serialization round-trip silently reset to the class default.
+     * The clone must not run through setters: a validating setter would 
re-check the
+     * default's own values, and a setter that rejects caller input would 
throw before the
+     * caller's JSON is even reached. Copying by field also preserves 
init-time state that
+     * has no getter, which a serialization round-trip silently reset to the 
class default.
      */
     private static final Map<ObjectMapper, ObjectMapper> COPY_MAPPERS = new 
ConcurrentHashMap<>();
 
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
index c73d67c293..7cb623d3c3 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ComponentNameResolver.java
@@ -100,8 +100,8 @@ public final class ComponentNameResolver {
      * Implementations of a wire-allowed type that break its promise: a wire 
request may name
      * them nowhere it could instantiate one. The embedding filters POST 
document text to a
      * configured endpoint, so a request must not supply the endpoint. Their 
flat per-request
-     * config ({@code skipEmbedding}) is unaffected: it tunes the loaded 
instance and is
-     * guarded by the filter's RuntimeConfig.
+     * config ({@code skipEmbedding}) is unaffected: it tunes the loaded 
instance, whose
+     * endpoint fields are {@code @OperatorOnly}.
      */
     private static final Set<String> WIRE_BLOCKED_COMPONENT_NAMES = new 
HashSet<>();
 
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
index b3c359b108..6efac2d92f 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/ConfigDeserializer.java
@@ -95,6 +95,10 @@ public class ConfigDeserializer {
             return defaultConfig;
         }
 
+        if (!jsonConfig.trusted()) {
+            OperatorOnlyGuard.check(MAPPER, configKey, jsonConfig.json(), 
configClass);
+        }
+
         // Deserialize and merge with default
         T config = JsonMergeUtils.mergeWithDefaults(MAPPER, jsonConfig.json(), 
configClass, defaultConfig);
 
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
 
b/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
new file mode 100644
index 0000000000..214b0e7ee5
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/serialization/OperatorOnlyGuard.java
@@ -0,0 +1,126 @@
+/*
+ * 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.tika.serialization;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.databind.BeanDescription;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyName;
+import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
+import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
+
+import org.apache.tika.config.OperatorOnly;
+
+/**
+ * Refuses per-request JSON that names an {@link OperatorOnly} property. Runs 
on the JSON
+ * tree before deserialization, so no setter sees the value. Properties are 
matched the way
+ * Jackson will bind them (declared name and aliases); a key Jackson does not 
know is left
+ * for Jackson to refuse. Object values are checked against the nested 
property's type.
+ */
+public final class OperatorOnlyGuard {
+
+    private record Property(boolean operatorOnly, JavaType type) { }
+
+    private static final Map<JavaType, Map<String, Property>> PROPERTIES = new 
ConcurrentHashMap<>();
+
+    private OperatorOnlyGuard() {
+    }
+
+    /**
+     * @throws IOException if {@code json} names an operator-only property of 
{@code configClass}
+     */
+    public static void check(ObjectMapper mapper, String configKey, String 
json, Class<?> configClass)
+            throws IOException {
+        check(mapper, configKey, mapper.readTree(json), 
mapper.constructType(configClass));
+    }
+
+    static void check(ObjectMapper mapper, String configKey, JsonNode node, 
JavaType type)
+            throws IOException {
+        if (node == null || !node.isObject()) {
+            return;
+        }
+        Map<String, Property> properties = PROPERTIES.computeIfAbsent(type, t 
-> introspect(mapper, t));
+        Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
+        while (fields.hasNext()) {
+            Map.Entry<String, JsonNode> e = fields.next();
+            Property property = properties.get(e.getKey());
+            if (property == null) {
+                continue;
+            }
+            if (property.operatorOnly()) {
+                throw new IOException("Cannot modify " + e.getKey() + " at 
runtime: per-request config for '"
+                        + configKey + "' may not set it. Configure it at 
initialization time "
+                        + "(tika-config.json or a preset).");
+            }
+            if (e.getValue().isObject() && !property.type().isContainerType()) 
{
+                check(mapper, configKey, e.getValue(), property.type());
+            }
+        }
+    }
+
+    private static Map<String, Property> introspect(ObjectMapper mapper, 
JavaType type) {
+        BeanDescription bean = 
mapper.getDeserializationConfig().introspect(type);
+        Map<String, Property> properties = new HashMap<>();
+        for (BeanPropertyDefinition def : bean.findProperties()) {
+            Property property = new Property(isOperatorOnly(def), 
def.getPrimaryType());
+            properties.put(def.getName(), property);
+            for (PropertyName alias : def.findAliases()) {
+                properties.put(alias.getSimpleName(), property);
+            }
+        }
+        return properties;
+    }
+
+    private static boolean isOperatorOnly(BeanPropertyDefinition def) {
+        for (AnnotatedMember member : new AnnotatedMember[]{def.getSetter(), 
def.getGetter(), def.getField()}) {
+            if (member != null && isAnnotated(member.getMember())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isAnnotated(Member member) {
+        if (member instanceof Field field) {
+            return field.isAnnotationPresent(OperatorOnly.class);
+        }
+        if (member instanceof Method method) {
+            // an override that drops the annotation must not unlock the 
property
+            for (Class<?> c = method.getDeclaringClass(); c != null; c = 
c.getSuperclass()) {
+                try {
+                    if (c.getDeclaredMethod(method.getName(), 
method.getParameterTypes())
+                            .isAnnotationPresent(OperatorOnly.class)) {
+                        return true;
+                    }
+                } catch (NoSuchMethodException ignored) {
+                    // not declared at this level
+                }
+            }
+        }
+        return false;
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
index 0bb75e8fcb..b1ac579f96 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
@@ -57,7 +57,7 @@ public class JsonMergeUtilsTest {
         }
     }
 
-    /** Stands in for a RuntimeConfig: {@code size} is locked, and a ceiling 
has no getter. */
+    /** A config with a locked setter and a ceiling that has no getter. */
     public static class LockedConfig extends Config {
         // no getter on purpose: init-time state a serialization round-trip 
would drop
         private int ceiling = 4096;
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
index 69f45580cd..6ac9e41b3d 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/serialization/ConfigDeserializerTest.java
@@ -64,11 +64,7 @@ public class ConfigDeserializerTest {
         }
     }
 
-    /**
-     * Stands in for the validation-only RuntimeConfig subclasses 
(TesseractOCRConfig,
-     * Tess4JConfig, VLMOCRConfig) that a component deserializes a key into 
before
-     * deserializing the same key into the real config class.
-     */
+    /** A second class resolved from the same key: each class gets its own 
cached instance. */
     public static class RuntimeTestConfig extends TestConfig {
     }
 
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
new file mode 100644
index 0000000000..adefd7d4b5
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/serialization/OperatorOnlyGuardTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.OperatorOnly;
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Per-request JSON may not name an {@link OperatorOnly} property; operator 
JSON may. The
+ * refusal is by JSON key before any setter runs, through aliases, into nested 
objects, and
+ * survives a subclass override that drops the annotation.
+ */
+public class OperatorOnlyGuardTest {
+
+    public static class Inner {
+        private String path = "default-inner";
+        private int depth = 1;
+
+        public String getPath() {
+            return path;
+        }
+
+        @OperatorOnly
+        public void setPath(String path) {
+            this.path = path;
+        }
+
+        public int getDepth() {
+            return depth;
+        }
+
+        public void setDepth(int depth) {
+            this.depth = depth;
+        }
+    }
+
+    public static class Config {
+        static volatile boolean executableSetterRan;
+
+        private String executable = "/usr/bin/default";
+        private String language = "eng";
+        private Inner inner = new Inner();
+
+        public String getExecutable() {
+            return executable;
+        }
+
+        @OperatorOnly
+        @JsonAlias("binary")
+        public void setExecutable(String executable) {
+            executableSetterRan = true;
+            this.executable = executable;
+        }
+
+        public String getLanguage() {
+            return language;
+        }
+
+        public void setLanguage(String language) {
+            this.language = language;
+        }
+
+        public Inner getInner() {
+            return inner;
+        }
+
+        public void setInner(Inner inner) {
+            this.inner = inner;
+        }
+    }
+
+    /** An override without the annotation. */
+    public static class SubConfig extends Config {
+        @Override
+        public void setExecutable(String executable) {
+            super.setExecutable(executable);
+        }
+    }
+
+    private static final String KEY = "my-parser";
+
+    private static <T> T resolve(String json, boolean trusted, Class<T> clazz, 
T defaults)
+            throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig(KEY, json, trusted);
+        return ParseContextConfig.getConfig(context, KEY, clazz, defaults);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"executable\": \"/tmp/evil\"}", 
"{\"executable\": null}",
+            "{\"binary\": \"/tmp/evil\"}", "{\"inner\": {\"depth\": 3, 
\"path\": \"/tmp/evil\"}}"})
+    public void testPerRequestIsRefusedBeforeAnySetterRuns(String json) {
+        Config.executableSetterRan = false;
+        IOException e = assertThrows(IOException.class, () -> resolve(json, 
false, Config.class, new Config()));
+        assertTrue(e.getMessage().contains("at runtime: per-request config for 
'" + KEY + "'"), e.getMessage());
+        assertFalse(Config.executableSetterRan);
+    }
+
+    @Test
+    public void testUnrelatedAndNestedPlainFieldsMerge() throws Exception {
+        Config merged = resolve("{\"language\": \"fra\", \"inner\": 
{\"depth\": 3}}", false,
+                Config.class, new Config());
+        assertEquals("fra", merged.getLanguage());
+        assertEquals(3, merged.getInner().getDepth());
+        assertEquals("/usr/bin/default", merged.getExecutable());
+    }
+
+    @Test
+    public void testOperatorConfigMaySetIt() throws Exception {
+        Config merged = resolve("{\"executable\": \"/opt/bin/x\", \"inner\": 
{\"path\": \"p\"}}", true,
+                Config.class, new Config());
+        assertEquals("/opt/bin/x", merged.getExecutable());
+        assertEquals("p", merged.getInner().getPath());
+    }
+
+    @Test
+    public void testOverrideWithoutAnnotationStaysLocked() {
+        assertThrows(IOException.class,
+                () -> resolve("{\"executable\": \"/tmp/evil\"}", false, 
SubConfig.class, new SubConfig()));
+    }
+
+    @Test
+    public void testUnknownKeyIsLeftToJackson() {
+        IOException e = assertThrows(IOException.class, () -> 
resolve("{\"nope\": 1}", false, Config.class, new Config()));
+        assertFalse(e.getMessage().contains("at runtime"), e.getMessage());
+    }
+}
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
index 27116d3a06..7414e7cd77 100644
--- 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/TikaResourceTest.java
@@ -568,6 +568,39 @@ public class TikaResourceTest extends CXFTestBase {
         assertEquals(422, response.getStatus());
     }
 
+    /** The factory class name loads and constructs the class it names: 
operator-only. */
+    @Test
+    public void testOperatorOnlyPdfConfigRefused() throws Exception {
+        String configJson = """
+                {
+                  "pdf-parser": {
+                    "imageGraphicsEngineFactoryClass": 
"org.apache.tika.server.standard.TikaResourceTest$ConstructorWitness"
+                  }
+                }
+                """;
+        ConstructorWitness.constructed = false;
+        ContentDisposition fileCd = new ContentDisposition("form-data; 
name=\"file\"; filename=\"testOCR.pdf\"");
+        Attachment fileAtt = new Attachment("file",
+                
ClassLoader.getSystemResourceAsStream("test-documents/testOCR.pdf"), fileCd);
+        Attachment configAtt = new Attachment("config", "application/json",
+                new 
java.io.ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8)));
+
+        Response response = WebClient
+                .create(endPoint + TIKA_PATH + "/config")
+                .type("multipart/form-data")
+                .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+        assertEquals(422, response.getStatus());
+        assertFalse(ConstructorWitness.constructed, "a class named by 
per-request config was constructed");
+    }
+
+    public static class ConstructorWitness {
+        static volatile boolean constructed;
+
+        public ConstructorWitness() {
+            constructed = true;
+        }
+    }
+
     @Test
     public void testFloatInConfig() throws Exception {
         String configJson = """
diff --git 
a/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java 
b/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java
new file mode 100644
index 0000000000..4262e1c593
--- /dev/null
+++ 
b/tika-test-support/src/main/java/org/apache/tika/test/OperatorOnlyAudit.java
@@ -0,0 +1,169 @@
+/*
+ * 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.tika.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.TreeSet;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * Finds config setters that look like they reach the host (a path, 
executable, class name,
+ * URL, credential, ...) but carry no {@code @OperatorOnly}. A module's test 
asserts the
+ * result is empty, so the next reflective or path-taking setter fails the 
build instead of
+ * shipping as a per-request hole.
+ * <p>
+ * Scope: every class on the test classpath under {@code org.apache.tika} 
whose simple name
+ * ends in {@code Config}, minus the package prefixes the caller excludes 
(surfaces that are
+ * never per-request). The name test is a heuristic by design: a reviewed 
false positive is
+ * annotated anyway, or excluded by the caller with a reason.
+ */
+public final class OperatorOnlyAudit {
+
+    /** Setter-name suffixes that usually mean host, not document. */
+    public static final Pattern SENSITIVE = Pattern.compile(
+            
"(?i).*(path|paths|executable|exe|command|cmd|binary|class|classname|url|uri|endpoint"
+                    + 
"|host|hostname|apikey|secret|credential|credentials|token|password|passwd"
+                    + 
"|bucket|region|model|dir|directory|file|keystore|truststore)$");
+
+    private static final String ANNOTATION = 
"org.apache.tika.config.OperatorOnly";
+
+    private OperatorOnlyAudit() {
+    }
+
+    /**
+     * @param excludedPackagePrefixes packages whose config is never 
per-request
+     * @param allowed                 reviewed {@code Class#setter} names to 
skip
+     * @return {@code Class#setter} for every unannotated sensitive setter, 
sorted
+     */
+    public static List<String> unannotatedSensitiveSetters(List<String> 
excludedPackagePrefixes,
+                                                           List<String> 
allowed) throws IOException {
+        ClassLoader loader = OperatorOnlyAudit.class.getClassLoader();
+        TreeSet<String> violations = new TreeSet<>();
+        for (String className : configClassNames()) {
+            if 
(excludedPackagePrefixes.stream().anyMatch(className::startsWith)) {
+                continue;
+            }
+            Class<?> clazz;
+            Method[] methods;
+            try {
+                clazz = Class.forName(className, false, loader);
+                methods = clazz.getDeclaredMethods();
+            } catch (Throwable e) {
+                // optional dependency missing from this classpath; not 
auditable here
+                continue;
+            }
+            for (Method m : methods) {
+                if (!isSetter(m) || 
!SENSITIVE.matcher(m.getName().substring(3)).matches()) {
+                    continue;
+                }
+                String id = clazz.getName() + "#" + m.getName();
+                if (allowed.contains(id) || isAnnotated(clazz, m)) {
+                    continue;
+                }
+                violations.add(id);
+            }
+        }
+        return new ArrayList<>(violations);
+    }
+
+    private static boolean isSetter(Method m) {
+        return Modifier.isPublic(m.getModifiers()) && 
!Modifier.isStatic(m.getModifiers())
+                && m.getName().startsWith("set") && m.getName().length() > 3
+                && m.getParameterCount() == 1 && !m.isSynthetic();
+    }
+
+    /** The setter, an override above it, or the matching getter/is-er carries 
the annotation. */
+    private static boolean isAnnotated(Class<?> clazz, Method setter) {
+        String property = setter.getName().substring(3);
+        for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+            for (Method m : c.getDeclaredMethods()) {
+                boolean sameSetter = m.getName().equals(setter.getName())
+                        && m.getParameterCount() == 1
+                        && 
m.getParameterTypes()[0].equals(setter.getParameterTypes()[0]);
+                boolean getter = m.getParameterCount() == 0
+                        && (m.getName().equals("get" + property) || 
m.getName().equals("is" + property));
+                if ((sameSetter || getter) && hasAnnotation(m)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean hasAnnotation(Method m) {
+        for (Annotation a : m.getAnnotations()) {
+            if (a.annotationType().getName().equals(ANNOTATION)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Every {@code org.apache.tika.**.*Config} class name on the test 
classpath. */
+    static List<String> configClassNames() throws IOException {
+        String classPath = System.getProperty("surefire.test.class.path",
+                System.getProperty("java.class.path"));
+        TreeSet<String> names = new TreeSet<>();
+        for (String entry : classPath.split(File.pathSeparator)) {
+            Path path = Paths.get(entry);
+            if (Files.isDirectory(path)) {
+                try (Stream<Path> walk = Files.walk(path)) {
+                    walk.filter(Files::isRegularFile)
+                            .map(p -> 
path.relativize(p).toString().replace(File.separatorChar, '/'))
+                            .forEach(rel -> addIfConfig(names, rel));
+                }
+            } else if (Files.isRegularFile(path)
+                    && 
path.toString().toLowerCase(Locale.ROOT).endsWith(".jar")) {
+                try (JarFile jar = new JarFile(path.toFile())) {
+                    Enumeration<JarEntry> entries = jar.entries();
+                    while (entries.hasMoreElements()) {
+                        addIfConfig(names, entries.nextElement().getName());
+                    }
+                }
+            }
+        }
+        return Collections.unmodifiableList(new ArrayList<>(names));
+    }
+
+    private static void addIfConfig(TreeSet<String> names, String resource) {
+        if (!resource.startsWith("org/apache/tika/") || 
!resource.endsWith(".class")) {
+            return;
+        }
+        String name = resource.substring(0, resource.length() - 
".class".length());
+        int slash = name.lastIndexOf('/');
+        int dollar = name.lastIndexOf('$');
+        String simple = name.substring(Math.max(slash, dollar) + 1);
+        if (simple.endsWith("Config")) {
+            names.add(name.replace('/', '.'));
+        }
+    }
+}

Reply via email to