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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new e2951f2064 [Feature][API] Add EXTENSION operator for pluggable 
validation in ConditionOperator (#11048)
e2951f2064 is described below

commit e2951f2064e568d7880097d2b5d37f72caf5d610
Author: zhiwei.niu <[email protected]>
AuthorDate: Fri Jun 12 16:56:07 2026 +0800

    [Feature][API] Add EXTENSION operator for pluggable validation in 
ConditionOperator (#11048)
---
 .../configuration-and-option-system.md             |  68 ++
 docs/en/engines/zeta/rest-api-v2.md                |  29 +-
 .../configuration-and-option-system.md             |  68 ++
 docs/zh/engines/zeta/rest-api-v2.md                |  29 +-
 .../api/configuration/util/Condition.java          |  20 +-
 .../configuration/util/ConditionEvaluators.java    |   8 +
 .../api/configuration/util/ConditionExtension.java |  63 ++
 .../api/configuration/util/ConditionOperator.java  |  15 +-
 .../api/configuration/util/Conditions.java         |   9 +-
 .../configuration/util/ConfigValidatorTest.java    | 706 ++++++++++++++++++++-
 .../api/configuration/util/OptionRuleTest.java     |  30 +
 .../seatunnel/command/MetadataExportCommand.java   |  10 +-
 .../command/MetadataExportCommandTest.java         |  91 +++
 .../server/rest/service/OptionRulesService.java    |   6 +-
 .../rest/service/OptionRulesServiceTest.java       |  43 ++
 15 files changed, 1175 insertions(+), 20 deletions(-)

diff --git a/docs/en/architecture/configuration-and-option-system.md 
b/docs/en/architecture/configuration-and-option-system.md
index c63fe1fb26..5ba5cc7828 100644
--- a/docs/en/architecture/configuration-and-option-system.md
+++ b/docs/en/architecture/configuration-and-option-system.md
@@ -131,6 +131,7 @@ Available operators (all accessed via the `Conditions` 
factory class):
 | Cross-field | `lessOrEqualField(option, other)` | value <= another option's 
value |
 | Cross-field | `greaterThanField(option, other)` | value > another option's 
value |
 | Cross-field | `greaterOrEqualField(option, other)` | value >= another 
option's value |
+| Extension | `Conditions.extension(option, ext)` | delegates to a 
`ConditionExtension<T>` implementation |
 
 :::tip
 Multiple conditions can be chained with `.and(...)` or `.or(...)` to form 
compound constraints. AND binds tighter than OR, so `A.or(B).and(C)` evaluates 
as `A || (B && C)`.
@@ -206,6 +207,7 @@ Quick reference:
 | Validate value only when trigger matches | `.conditional(trigger, value, 
condition...)` |
 | Optional field with value check when present | `.optional(opt, 
condition...)` |
 | Cross-field comparisons | `Conditions.lessThanField/greaterThanField(...)` |
+| Custom / structural validation | `Conditions.extension(opt, ext)` |
 
 ### Required fields
 
@@ -395,6 +397,72 @@ When two optional fields are provided together, their 
values must satisfy a cros
         Conditions.lessThanField(START_TS, END_TS))
 ```
 
+### Custom validation with Extension
+
+When built-in operators are not expressive enough — for example, validating 
the internal structure of a `List<Map>` or enforcing cross-key constraints 
inside nested configs — use the `EXTENSION` operator.
+
+Implement `ConditionExtension<T>` and wire it via 
`Conditions.extension(option, ext)`. The extension plugs into the same 
`valueConstraints` pipeline as all built-in operators, so it works with 
`.and()` / `.or()`, `required`, `optional`, and `conditional` rules.
+
+Inline anonymous class:
+
+```java
+.optional(API_KEY_ENCODED, Conditions.extension(API_KEY_ENCODED,
+        new ConditionExtension<String>() {
+            @Override
+            public String description() {
+                return "must be Base64-encoded 'id:api_key'";
+            }
+
+            @Override
+            public boolean evaluate(ReadonlyConfig cfg, String v)
+                    throws OptionValidationException {
+                try {
+                    return new 
String(Base64.getDecoder().decode(v)).contains(":");
+                } catch (IllegalArgumentException e) {
+                    return false;
+                }
+            }
+        }))
+```
+
+Static inner class for complex types:
+
+```java
+static class TableConfigsValidator
+        implements ConditionExtension<List<Map<String, Object>>> {
+    @Override
+    public String description() {
+        return "each entry must contain a non-empty 'table_name', and all 
table names must be unique";
+    }
+
+    @Override
+    public boolean evaluate(ReadonlyConfig config, List<Map<String, Object>> 
value) throws OptionValidationException {
+        if (value.isEmpty()) {
+            return false;
+        }
+        Set<String> seen = new HashSet<>();
+        for (Map<String, Object> entry : value) {
+            Object name = entry.get("table_name");
+            if (!(name instanceof String) || ((String) name).isEmpty()) {
+                return false;
+            }
+            if (!seen.add((String) name)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
+
+.exclusive(TABLE_CONFIGS, SCHEMA)
+.optional(TABLE_CONFIGS,
+        Conditions.extension(TABLE_CONFIGS, new TableConfigsValidator()))
+```
+
+:::caution
+`ConditionExtension.evaluate()` runs during job submission validation only. 
REST metadata queries only serialize `description()` and do not invoke 
`evaluate()`. Implementations should avoid I/O (database connections, HTTP 
calls, file access) and only validate structure and values.
+:::
+
 ## Why It Matters For Operators
 
 This architecture is also what makes the `option-rules` REST endpoint useful. 
Tools can inspect the runtime metadata of installed connectors and dynamically 
understand:
diff --git a/docs/en/engines/zeta/rest-api-v2.md 
b/docs/en/engines/zeta/rest-api-v2.md
index ccde0b04cb..672e3f962d 100644
--- a/docs/en/engines/zeta/rest-api-v2.md
+++ b/docs/en/engines/zeta/rest-api-v2.md
@@ -110,6 +110,10 @@ Please refer [security](security.md)
               ]
             },
             "expectValue": "TEMPLATE",
+            "compareOperator": null,
+            "compareOption": null,
+            "conditionOperator": "EQUAL",
+            "conditionOperatorCategory": "EQUALITY",
             "operator": null,
             "next": null
           },
@@ -139,6 +143,26 @@ Please refer [security](security.md)
           "operator": null,
           "next": null
         }
+      },
+      {
+        "expression": "'port' must be between 1 and 65535",
+        "conditionTree": {
+          "option": {
+            "key": "port",
+            "type": "java.lang.Integer",
+            "defaultValue": null,
+            "description": "Server port",
+            "fallbackKeys": [],
+            "optionValues": null
+          },
+          "expectValue": "must be between 1 and 65535",
+          "compareOperator": "extension",
+          "compareOption": null,
+          "conditionOperator": "EXTENSION",
+          "conditionOperatorCategory": "EXTENSION",
+          "operator": null,
+          "next": null
+        }
       }
     ]
   }
@@ -151,8 +175,9 @@ Please refer [security](security.md)
 - `optionRule.conditionRules` recursively exposes nested conditional option 
rules and is an empty array when the connector does not define nested rules.
 - For conditional rules, both `expression` and `expressionTree` are returned 
for dynamic form rendering.
 - `optionRule.valueConstraints` describes value-level validation rules such as 
numeric ranges, string patterns, and cross-field comparisons. Each entry 
provides a human-readable `expression` string alongside a structured 
`conditionTree` for programmatic use. This array is empty when the connector 
does not define any value constraints.
-- Within `conditionTree`, the `compareOperator` field (e.g. `>=`, `<`, `>`) 
and `compareOption` field are populated for numeric and cross-field 
comparisons. For equality checks and other non-comparison conditions, these 
fields are `null`.
-- The `conditionOperator` field provides a stable, machine-readable operator 
identifier (e.g. `GREATER_OR_EQUAL`, `NOT_BLANK`, `FIELD_LESS_THAN`), while 
`conditionOperatorCategory` indicates the operator's category (e.g. `NUMERIC`, 
`STRING`, `COLLECTION`, `EQUALITY`). These two fields are designed for 
programmatic consumption by frontend applications and automation tools.
+- Within `conditionTree`, the `compareOperator` field is `null` for `EQUAL` 
and otherwise uses the operator symbol exposed by the runtime rule (for example 
`>=`, `is not blank`, or `extension`). The `compareOption` field is populated 
only for cross-field comparisons.
+- `conditionOperator` is a stable operator identifier. Possible values include 
`EQUAL`, `GREATER_OR_EQUAL`, `NOT_BLANK`, `FIELD_LESS_THAN`, `EXTENSION`, etc. 
`conditionOperatorCategory` indicates the operator category, such as `NUMERIC`, 
`STRING`, `COLLECTION`, `EQUALITY`, `EXTENSION`, etc.
+- For `EXTENSION` conditions, `expectValue` carries the rule description text 
returned by `ConditionExtension.description()`.
 
 </details>
 
diff --git a/docs/zh/architecture/configuration-and-option-system.md 
b/docs/zh/architecture/configuration-and-option-system.md
index e9b956a998..7f437ff246 100644
--- a/docs/zh/architecture/configuration-and-option-system.md
+++ b/docs/zh/architecture/configuration-and-option-system.md
@@ -131,6 +131,7 @@ public OptionRule optionRule() {
 | 跨字段 | `lessOrEqualField(option, other)` | 值 <= 另一个配置项的值 |
 | 跨字段 | `greaterThanField(option, other)` | 值 > 另一个配置项的值 |
 | 跨字段 | `greaterOrEqualField(option, other)` | 值 >= 另一个配置项的值 |
+| 扩展 | `Conditions.extension(option, ext)` | 委托给 `ConditionExtension<T>` 
实现类执行自定义校验 |
 
 :::tip
 多个条件可以通过 `.and(...)` 或 `.or(...)` 链式组合成复合约束。AND 优先级高于 OR,因此 `A.or(B).and(C)` 
等价于 `A || (B && C)`。
@@ -206,6 +207,7 @@ Option validation failed (4 errors):
 | 条件触发的值校验 | `.conditional(trigger, value, condition...)` |
 | 可选字段(提供时校验) | `.optional(opt, condition...)` |
 | 跨字段比较 | `Conditions.lessThanField/greaterThanField(...)` |
+| 自定义 / 结构化校验 | `Conditions.extension(opt, ext)` |
 
 ### 必填字段
 
@@ -395,6 +397,72 @@ AND 优先级高于 OR,因此 `A.or(B.and(C))` 等价于 `A || (B && C)`。适
         Conditions.lessThanField(START_TS, END_TS))
 ```
 
+### 自定义校验(Extension 扩展算子)
+
+当内置算子无法满足需求时 — 例如校验 `List<Map>` 内部结构,或对嵌套配置做跨 key 约束 — 使用 `EXTENSION` 算子。
+
+实现 `ConditionExtension<T>` 接口,通过 `Conditions.extension(option, ext)` 
接入。扩展算子复用与内置算子完全相同的 `valueConstraints` 管线,支持 `.and()` / `.or()` 链式组合,适用于 
`required`、`optional`、`conditional` 等所有规则类型。
+
+匿名内部类写法:
+
+```java
+.optional(API_KEY_ENCODED, Conditions.extension(API_KEY_ENCODED,
+        new ConditionExtension<String>() {
+            @Override
+            public String description() {
+                return "must be Base64-encoded 'id:api_key'";
+            }
+
+            @Override
+            public boolean evaluate(ReadonlyConfig cfg, String v)
+                    throws OptionValidationException {
+                try {
+                    return new 
String(Base64.getDecoder().decode(v)).contains(":");
+                } catch (IllegalArgumentException e) {
+                    return false;
+                }
+            }
+        }))
+```
+
+静态内部类写法(适合复杂类型):
+
+```java
+static class TableConfigsValidator
+        implements ConditionExtension<List<Map<String, Object>>> {
+    @Override
+    public String description() {
+        return "each entry must contain a non-empty 'table_name', and all 
table names must be unique";
+    }
+
+    @Override
+    public boolean evaluate(ReadonlyConfig config, List<Map<String, Object>> 
value) throws OptionValidationException {
+        if (value.isEmpty()) {
+            return false;
+        }
+        Set<String> seen = new HashSet<>();
+        for (Map<String, Object> entry : value) {
+            Object name = entry.get("table_name");
+            if (!(name instanceof String) || ((String) name).isEmpty()) {
+                return false;
+            }
+            if (!seen.add((String) name)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
+
+.exclusive(TABLE_CONFIGS, SCHEMA)
+.optional(TABLE_CONFIGS,
+        Conditions.extension(TABLE_CONFIGS, new TableConfigsValidator()))
+```
+
+:::caution
+`ConditionExtension.evaluate()` 仅在作业提交校验时执行,REST 元数据查询只序列化 
`description()`,不会调用 `evaluate()`。实现时应避免 I/O 操作(如数据库连接、HTTP 请求、文件读写),只做结构和值校验。
+:::
+
 ## 为什么这对运维也重要
 
 这套设计也是 `option-rules` REST 接口能够成立的原因。运维平台或 UI 可以通过运行时元数据动态获知:
diff --git a/docs/zh/engines/zeta/rest-api-v2.md 
b/docs/zh/engines/zeta/rest-api-v2.md
index 7cbc0e43f2..b5f3d13658 100644
--- a/docs/zh/engines/zeta/rest-api-v2.md
+++ b/docs/zh/engines/zeta/rest-api-v2.md
@@ -108,6 +108,10 @@ seatunnel:
               ]
             },
             "expectValue": "TEMPLATE",
+            "compareOperator": null,
+            "compareOption": null,
+            "conditionOperator": "EQUAL",
+            "conditionOperatorCategory": "EQUALITY",
             "operator": null,
             "next": null
           },
@@ -137,6 +141,26 @@ seatunnel:
           "operator": null,
           "next": null
         }
+      },
+      {
+        "expression": "'port' must be between 1 and 65535",
+        "conditionTree": {
+          "option": {
+            "key": "port",
+            "type": "java.lang.Integer",
+            "defaultValue": null,
+            "description": "Server port",
+            "fallbackKeys": [],
+            "optionValues": null
+          },
+          "expectValue": "must be between 1 and 65535",
+          "compareOperator": "extension",
+          "compareOption": null,
+          "conditionOperator": "EXTENSION",
+          "conditionOperatorCategory": "EXTENSION",
+          "operator": null,
+          "next": null
+        }
       }
     ]
   }
@@ -149,8 +173,9 @@ seatunnel:
 - `optionRule.conditionRules` 会递归返回嵌套条件规则;当 connector 未定义嵌套规则时,该字段返回空数组。
 - 对于条件规则,会同时返回 `expression` 和 `expressionTree`,便于 Web 做动态表单渲染。
 - `optionRule.valueConstraints` 
描述值级别的校验规则,包括数值范围、字符串模式匹配以及跨字段比较等。每个条目同时提供人类可读的 `expression` 字符串和便于程序处理的结构化 
`conditionTree`。当连接器未定义值约束时,该数组为空。
-- 在 `conditionTree` 中,`compareOperator` 字段(如 `>=`、`<`、`>`)和 `compareOption` 
字段用于数值比较和跨字段比较场景;对于等值判断及其他非比较类条件,这两个字段为 `null`。
-- `conditionOperator` 字段提供稳定的、机器可读的操作符标识(如 
`GREATER_OR_EQUAL`、`NOT_BLANK`、`FIELD_LESS_THAN`),`conditionOperatorCategory` 
字段标明操作符所属分类(如 
`NUMERIC`、`STRING`、`COLLECTION`、`EQUALITY`)。这两个字段专为前端应用和自动化工具的程序化消费而设计。
+- 在 `conditionTree` 中,`compareOperator` 字段在 `EQUAL` 场景下为 
`null`,其他情况下会返回运行时规则暴露的操作符符号,例如 `>=`、`is not blank` 或 
`extension`。`compareOption` 字段仅在跨字段比较场景下有值。
+- `conditionOperator` 是稳定的操作符标识,可选值包括 
`EQUAL`、`GREATER_OR_EQUAL`、`NOT_BLANK`、`FIELD_LESS_THAN`、`EXTENSION` 
等;`conditionOperatorCategory` 是操作符的分类,可选值包括 
`NUMERIC`、`STRING`、`COLLECTION`、`EQUALITY`、`EXTENSION` 等。
+- 对于 `EXTENSION` 条件,`expectValue` 承载的是 `ConditionExtension.description()` 
返回的规则说明文本。
 
 </details>
 
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java
index 5511b7ce7d..e94c21b904 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java
@@ -32,15 +32,25 @@ public class Condition<T> {
     private final T expectValue;
     private final ConditionOperator operator;
     private final Option<?> compareOption;
+    private final ConditionExtension<T> extension;
     private Boolean and = null;
     private Condition<?> next = null;
 
     Condition(Option<T> option, T expectValue) {
-        this(option, ConditionOperator.EQUAL, expectValue, null);
+        this(option, ConditionOperator.EQUAL, expectValue, null, null);
     }
 
     Condition(
             Option<T> option, ConditionOperator operator, T expectValue, 
Option<?> compareOption) {
+        this(option, operator, expectValue, compareOption, null);
+    }
+
+    Condition(
+            Option<T> option,
+            ConditionOperator operator,
+            T expectValue,
+            Option<?> compareOption,
+            ConditionExtension<T> extension) {
         if (option == null) {
             throw new IllegalArgumentException("Condition option must not be 
null");
         }
@@ -61,10 +71,15 @@ public class Condition<T> {
                             "Operator %s requires an expectValue, but 
expectValue is null",
                             operator.name()));
         }
+        if (operator == ConditionOperator.EXTENSION && extension == null) {
+            throw new IllegalArgumentException(
+                    "Operator EXTENSION requires a non-null 
ConditionExtension");
+        }
         this.option = option;
         this.operator = operator;
         this.expectValue = expectValue;
         this.compareOption = compareOption;
+        this.extension = extension;
     }
 
     public static <T> Condition<T> of(Option<T> option, T expectValue) {
@@ -219,6 +234,9 @@ public class Condition<T> {
         ConditionOperator op = cond.operator;
         String key = "'" + cond.option.key() + "'";
 
+        if (op == ConditionOperator.EXTENSION) {
+            return key + " " + cond.extension.description();
+        }
         if (op.getSource() == ConditionOperator.Source.FIELD) {
             return key + " " + op.getSymbol() + " '" + 
cond.compareOption.key() + "'";
         }
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java
index 82a72d08e9..eacb9566e6 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java
@@ -169,6 +169,14 @@ public final class ConditionEvaluators {
                     return compareNumbers(v, other) >= 0;
                 });
 
+        // Extension (custom logic delegated to ConditionExtension)
+        m.put(
+                ConditionOperator.EXTENSION,
+                (v, c, cfg) -> {
+                    ConditionExtension<Object> ext = 
(ConditionExtension<Object>) c.getExtension();
+                    return ext.evaluate(cfg, v);
+                });
+
         for (ConditionOperator op : ConditionOperator.values()) {
             if (!m.containsKey(op)) {
                 throw new IllegalStateException(
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
new file mode 100644
index 0000000000..2048059678
--- /dev/null
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
@@ -0,0 +1,63 @@
+/*
+ * 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.seatunnel.api.configuration.util;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+
+/**
+ * Pluggable validation extension for cases where built-in {@link 
ConditionOperator} operators are
+ * not expressive enough — for example, validating the internal structure of a 
{@code List<Map>} or
+ * enforcing cross-key constraints inside nested configs.
+ *
+ * <p>Wire an implementation via {@link
+ * Conditions#extension(org.apache.seatunnel.api.configuration.Option, 
ConditionExtension)}. The
+ * extension plugs into the same {@code valueConstraints} pipeline as all 
built-in operators and
+ * supports {@code .and()} / {@code .or()} chaining, {@code required}, {@code 
optional}, and {@code
+ * conditional} rules.
+ *
+ * <p>Implementations should avoid I/O (database connections, HTTP calls, file 
access) and only
+ * validate structure and values. {@link #evaluate} runs only during job 
submission validation; REST
+ * metadata queries only serialize {@link #description()}.
+ *
+ * @param <T> the option value type
+ */
+public interface ConditionExtension<T> {
+
+    /**
+     * Rule description used in error messages ({@link Condition#toString()}) 
and metadata
+     * serialization (REST {@code /option-rules} and CLI metadata export).
+     *
+     * @return non-null description, e.g. {@code "must be between 1 and 65535"}
+     */
+    String description();
+
+    /**
+     * Evaluates whether {@code value} passes this validation rule.
+     *
+     * <p>Return {@code false} for simple failure — the framework composes the 
error from {@link
+     * #description()} automatically. Throw {@link OptionValidationException} 
when a richer,
+     * context-specific message is needed. Avoid other unchecked exceptions — 
they propagate
+     * unwrapped.
+     *
+     * @param config full configuration context (read-only), available for 
cross-field checks
+     * @param value the resolved option value; may be {@code null}
+     * @return {@code true} if valid
+     * @throws OptionValidationException for detailed error reporting
+     */
+    boolean evaluate(ReadonlyConfig config, T value) throws 
OptionValidationException;
+}
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java
index 8dbd07288f..632c444a0b 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java
@@ -61,24 +61,31 @@ public enum ConditionOperator {
     FIELD_LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.FIELD),
     FIELD_LESS_OR_EQUAL("<=", Category.NUMERIC, Arity.BINARY, Source.FIELD),
     FIELD_GREATER_THAN(">", Category.NUMERIC, Arity.BINARY, Source.FIELD),
-    FIELD_GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.FIELD);
+    FIELD_GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.FIELD),
+
+    // ==================== Extension ====================
+
+    EXTENSION("extension", Category.EXTENSION, Arity.EXTENSION, 
Source.EXTENSION);
 
     public enum Category {
         EQUALITY,
         NUMERIC,
         STRING,
         COLLECTION,
-        MAP
+        MAP,
+        EXTENSION
     }
 
     public enum Arity {
         UNARY,
-        BINARY
+        BINARY,
+        EXTENSION
     }
 
     public enum Source {
         LITERAL,
-        FIELD
+        FIELD,
+        EXTENSION
     }
 
     private final String symbol;
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java
index a510abbf17..90ad9beda1 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java
@@ -36,8 +36,6 @@ import java.util.List;
  *     .build();
  * }</pre>
  *
- * <p>Currently supported operators (19 total, 5 categories):
- *
  * <ul>
  *   <li><b>Numeric</b>: {@code greaterThan}, {@code greaterOrEqual}, {@code 
lessThan}, {@code
  *       lessOrEqual}
@@ -47,6 +45,7 @@ import java.util.List;
  *   <li><b>Map</b>: {@code mapNotEmpty}, {@code mapContainsKey}, {@code 
mapContainsKeys}
  *   <li><b>Cross-field</b>: {@code lessThanField}, {@code lessOrEqualField}, 
{@code
  *       greaterThanField}, {@code greaterOrEqualField}
+ *   <li><b>Extension</b>: {@code extension} (custom logic via {@link 
ConditionExtension})
  * </ul>
  *
  * <p>Additionally, equality checks are available via {@link 
Condition#of(Option, Object)} (EQUAL)
@@ -139,4 +138,10 @@ public final class Conditions {
     public static <T> Condition<T> greaterOrEqualField(Option<T> option, 
Option<T> other) {
         return new Condition<>(option, 
ConditionOperator.FIELD_GREATER_OR_EQUAL, null, other);
     }
+
+    // ==================== Extension (pluggable validation) 
====================
+
+    public static <T> Condition<T> extension(Option<T> option, 
ConditionExtension<T> ext) {
+        return new Condition<>(option, ConditionOperator.EXTENSION, null, 
null, ext);
+    }
 }
diff --git 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
index fec4b5e05b..2b859bb059 100644
--- 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
+++ 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
@@ -17,6 +17,8 @@
 
 package org.apache.seatunnel.api.configuration.util;
 
+import 
org.apache.seatunnel.shade.com.fasterxml.jackson.core.type.TypeReference;
+
 import org.apache.seatunnel.api.configuration.Option;
 import org.apache.seatunnel.api.configuration.OptionTest;
 import org.apache.seatunnel.api.configuration.Options;
@@ -407,7 +409,6 @@ public class ConfigValidatorTest {
     @Test
     public void testMultipleValueNestedRule() {
         OptionRule subOption1 = OptionRule.builder().required(KEY_USERNAME, 
KEY_PASSWORD).build();
-        OptionRule subOption2 = 
OptionRule.builder().required(KEY_BEARER_TOKEN).build();
         OptionRule optionRule =
                 OptionRule.builder()
                         .optional(SINGLE_CHOICE_VALUE_TEST)
@@ -453,9 +454,6 @@ public class ConfigValidatorTest {
     public static final Option<String> DB_NAME =
             
Options.key("db_name").stringType().noDefaultValue().withDescription("database 
name");
 
-    public static final Option<String> DELIMITER =
-            
Options.key("delimiter").stringType().noDefaultValue().withDescription("delimiter");
-
     public static final Option<Long> START_TS =
             
Options.key("start_ts").longType().noDefaultValue().withDescription("start 
timestamp");
 
@@ -2753,7 +2751,7 @@ public class ConfigValidatorTest {
 
         // list option present with valid list -> pass
         Map<String, Object> config6 = new HashMap<>();
-        config6.put(TEST_TOPIC.key(), Arrays.asList("topic1"));
+        config6.put(TEST_TOPIC.key(), Collections.singletonList("topic1"));
         Assertions.assertDoesNotThrow(() -> validate(config6, rule));
     }
 
@@ -2787,4 +2785,702 @@ public class ConfigValidatorTest {
         config4.put(TEST_TOPIC.key(), Collections.emptyList());
         assertThrows(OptionValidationException.class, () -> validate(config4, 
rule));
     }
+
+    static final Option<Integer> EXT_PORT =
+            
Options.key("ext.port").intType().noDefaultValue().withDescription("port");
+
+    static final Option<Integer> OPT_PORT =
+            
Options.key("opt.port").intType().defaultValue(8080).withDescription("optional 
port");
+
+    static class PortRangeExtension implements ConditionExtension<Integer> {
+        @Override
+        public String description() {
+            return "must be between 1 and 65535";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, Integer value)
+                throws OptionValidationException {
+            return value != null && value >= 1 && value <= 65535;
+        }
+    }
+
+    @Test
+    public void testExtensionRequiredPass() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                EXT_PORT, Conditions.extension(EXT_PORT, new 
PortRangeExtension()))
+                        .build();
+        Map<String, Object> config = new HashMap<>();
+        config.put("ext.port", 8080);
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionRequiredFail() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                EXT_PORT, Conditions.extension(EXT_PORT, new 
PortRangeExtension()))
+                        .build();
+        Map<String, Object> config = new HashMap<>();
+        config.put("ext.port", 99999);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionOptionalAbsentSkip() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .optional(
+                                OPT_PORT, Conditions.extension(OPT_PORT, new 
PortRangeExtension()))
+                        .build();
+        Map<String, Object> config = new HashMap<>();
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionOptionalPresentPass() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .optional(
+                                OPT_PORT, Conditions.extension(OPT_PORT, new 
PortRangeExtension()))
+                        .build();
+        Map<String, Object> config = new HashMap<>();
+        config.put("opt.port", 443);
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionOptionalPresentFail() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .optional(
+                                OPT_PORT, Conditions.extension(OPT_PORT, new 
PortRangeExtension()))
+                        .build();
+        Map<String, Object> config = new HashMap<>();
+        config.put("opt.port", 0);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionAndBuiltinCombined() {
+        Condition<Integer> combined =
+                greaterOrEqual(EXT_PORT, 1)
+                        .and(Conditions.extension(EXT_PORT, new 
PortRangeExtension()));
+        OptionRule rule = OptionRule.builder().required(EXT_PORT, 
combined).build();
+
+        Map<String, Object> pass = new HashMap<>();
+        pass.put("ext.port", 80);
+        Assertions.assertDoesNotThrow(() -> validate(pass, rule));
+
+        Map<String, Object> fail = new HashMap<>();
+        fail.put("ext.port", 70000);
+        assertThrows(OptionValidationException.class, () -> validate(fail, 
rule));
+    }
+
+    @Test
+    public void testExtensionOrBuiltinCombined() {
+        ConditionExtension<Integer> isWellKnown =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "is a well-known port (1-1023)";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value)
+                            throws OptionValidationException {
+                        return value != null && value >= 1 && value <= 1023;
+                    }
+                };
+        Condition<Integer> combined =
+                Conditions.extension(EXT_PORT, isWellKnown)
+                        .or(Condition.of(EXT_PORT, ConditionOperator.EQUAL, 
8080));
+        OptionRule rule = OptionRule.builder().required(EXT_PORT, 
combined).build();
+
+        Map<String, Object> passWellKnown = new HashMap<>();
+        passWellKnown.put("ext.port", 443);
+        Assertions.assertDoesNotThrow(() -> validate(passWellKnown, rule));
+
+        Map<String, Object> passExact = new HashMap<>();
+        passExact.put("ext.port", 8080);
+        Assertions.assertDoesNotThrow(() -> validate(passExact, rule));
+
+        Map<String, Object> fail = new HashMap<>();
+        fail.put("ext.port", 5000);
+        assertThrows(OptionValidationException.class, () -> validate(fail, 
rule));
+    }
+
+    @Test
+    public void testExtensionConditionToString() {
+        Condition<Integer> cond = Conditions.extension(EXT_PORT, new 
PortRangeExtension());
+        assertEquals("'ext.port' must be between 1 and 65535", 
cond.toString());
+    }
+
+    @Test
+    public void testExtensionConditionEquals() {
+        Condition<Integer> a = Conditions.extension(EXT_PORT, new 
PortRangeExtension());
+        Condition<Integer> b = Conditions.extension(EXT_PORT, new 
PortRangeExtension());
+        assertEquals(a, b);
+        assertEquals(a.hashCode(), b.hashCode());
+
+        Condition<Integer> c =
+                Conditions.extension(
+                        EXT_PORT,
+                        new ConditionExtension<Integer>() {
+                            @Override
+                            public String description() {
+                                return "different impl";
+                            }
+
+                            @Override
+                            public boolean evaluate(ReadonlyConfig config, 
Integer value) {
+                                return value != null;
+                            }
+                        });
+        assertEquals(a, c);
+        assertEquals(a.hashCode(), c.hashCode());
+    }
+
+    static final Option<List<Map<String, Object>>> LIST_MAP_OPTION =
+            Options.key("rules")
+                    .type(new TypeReference<List<Map<String, Object>>>() {})
+                    .noDefaultValue()
+                    .withDescription("list of rule maps");
+
+    static final Option<Map<String, List<Map<String, Object>>>> 
NESTED_MAP_OPTION =
+            Options.key("nested.config")
+                    .type(new TypeReference<Map<String, List<Map<String, 
Object>>>>() {})
+                    .noDefaultValue()
+                    .withDescription("nested map of list of maps");
+
+    static class ListMapStructureExtension
+            implements ConditionExtension<List<Map<String, Object>>> {
+        @Override
+        public String description() {
+            return "each rule must contain 'field' and 'type' keys";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, List<Map<String, 
Object>> value)
+                throws OptionValidationException {
+            if (value == null || value.isEmpty()) return false;
+            for (Map<String, Object> rule : value) {
+                if (!rule.containsKey("field") || !rule.containsKey("type")) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    static class NestedMapExtension
+            implements ConditionExtension<Map<String, List<Map<String, 
Object>>>> {
+        @Override
+        public String description() {
+            return "each group must have non-empty rules with 'name' key";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, Map<String, 
List<Map<String, Object>>> value)
+                throws OptionValidationException {
+            if (value == null || value.isEmpty()) return false;
+            for (Map.Entry<String, List<Map<String, Object>>> entry : 
value.entrySet()) {
+                List<Map<String, Object>> rules = entry.getValue();
+                if (rules == null || rules.isEmpty()) return false;
+                for (Map<String, Object> rule : rules) {
+                    if (!rule.containsKey("name")) return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    @Test
+    public void testExtensionListMapValidStructure() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> ruleItem1 = new HashMap<>();
+        ruleItem1.put("field", "name");
+        ruleItem1.put("type", "string");
+        Map<String, Object> ruleItem2 = new HashMap<>();
+        ruleItem2.put("field", "age");
+        ruleItem2.put("type", "int");
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Arrays.asList(ruleItem1, ruleItem2));
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionListMapMissingKey() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> ruleItem = new HashMap<>();
+        ruleItem.put("field", "name");
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Collections.singletonList(ruleItem));
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionListMapEmptyList() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Collections.emptyList());
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionListMapNullValue() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", null);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionListMapSingleItemValid() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> item = new HashMap<>();
+        item.put("field", "id");
+        item.put("type", "long");
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Collections.singletonList(item));
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionListMapPartialInvalid() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> good = new HashMap<>();
+        good.put("field", "id");
+        good.put("type", "long");
+        Map<String, Object> bad = new HashMap<>();
+        bad.put("field", "name");
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Arrays.asList(good, bad));
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionNestedMapValid() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                NESTED_MAP_OPTION,
+                                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension()))
+                        .build();
+
+        Map<String, Object> item1 = new HashMap<>();
+        item1.put("name", "rule1");
+        Map<String, Object> item2 = new HashMap<>();
+        item2.put("name", "rule2");
+
+        Map<String, Object> nested = new HashMap<>();
+        nested.put("group_a", Arrays.asList(item1, item2));
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("nested.config", nested);
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionNestedMapEmptyGroup() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                NESTED_MAP_OPTION,
+                                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension()))
+                        .build();
+
+        Map<String, Object> nested = new HashMap<>();
+        nested.put("group_a", Collections.emptyList());
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("nested.config", nested);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionNestedMapMissingNameKey() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                NESTED_MAP_OPTION,
+                                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension()))
+                        .build();
+
+        Map<String, Object> item = new HashMap<>();
+        item.put("value", "something");
+        Map<String, Object> nested = new HashMap<>();
+        nested.put("group_a", Collections.singletonList(item));
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("nested.config", nested);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionNestedMapEmptyOuter() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                NESTED_MAP_OPTION,
+                                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension()))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("nested.config", Collections.emptyMap());
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionNestedMapMultiGroupPartialInvalid() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                NESTED_MAP_OPTION,
+                                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension()))
+                        .build();
+
+        Map<String, Object> good = new HashMap<>();
+        good.put("name", "ok");
+        Map<String, Object> bad = new HashMap<>();
+        bad.put("other", "missing name");
+        Map<String, Object> nested = new HashMap<>();
+        nested.put("group_a", Collections.singletonList(good));
+        nested.put("group_b", Collections.singletonList(bad));
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("nested.config", nested);
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+    }
+
+    @Test
+    public void testExtensionThrowsOptionValidationException() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(
+                                EXT_PORT,
+                                Conditions.extension(
+                                        EXT_PORT,
+                                        new ConditionExtension<Integer>() {
+                                            @Override
+                                            public String description() {
+                                                return "must be even";
+                                            }
+
+                                            @Override
+                                            public boolean evaluate(
+                                                    ReadonlyConfig config, 
Integer value)
+                                                    throws 
OptionValidationException {
+                                                if (value != null && value % 2 
!= 0) {
+                                                    throw new 
OptionValidationException(
+                                                            "Value %d is odd, 
must be even", value);
+                                                }
+                                                return value != null;
+                                            }
+                                        }))
+                        .build();
+
+        Map<String, Object> pass = new HashMap<>();
+        pass.put("ext.port", 80);
+        Assertions.assertDoesNotThrow(() -> validate(pass, rule));
+
+        Map<String, Object> fail = new HashMap<>();
+        fail.put("ext.port", 81);
+        OptionValidationException ex =
+                assertThrows(OptionValidationException.class, () -> 
validate(fail, rule));
+        Assertions.assertTrue(ex.getMessage().contains("odd"));
+    }
+
+    @Test
+    public void testExtensionListMapOptionalAbsent() {
+        OptionRule rule =
+                OptionRule.builder()
+                        .optional(
+                                LIST_MAP_OPTION,
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        Assertions.assertDoesNotThrow(() -> validate(config, rule));
+    }
+
+    @Test
+    public void testExtensionListMapToString() {
+        Condition<List<Map<String, Object>>> cond =
+                Conditions.extension(LIST_MAP_OPTION, new 
ListMapStructureExtension());
+        assertEquals("'rules' each rule must contain 'field' and 'type' keys", 
cond.toString());
+    }
+
+    @Test
+    public void testExtensionNestedMapToString() {
+        Condition<Map<String, List<Map<String, Object>>>> cond =
+                Conditions.extension(NESTED_MAP_OPTION, new 
NestedMapExtension());
+        assertEquals(
+                "'nested.config' each group must have non-empty rules with 
'name' key",
+                cond.toString());
+    }
+
+    @Test
+    public void testExtensionListMapAndBuiltinChain() {
+        Condition<List<Map<String, Object>>> combined =
+                notEmpty(LIST_MAP_OPTION)
+                        .and(
+                                Conditions.extension(
+                                        LIST_MAP_OPTION, new 
ListMapStructureExtension()));
+        OptionRule rule = OptionRule.builder().required(LIST_MAP_OPTION, 
combined).build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("rules", Collections.emptyList());
+        assertThrows(OptionValidationException.class, () -> validate(config, 
rule));
+
+        Map<String, Object> good = new HashMap<>();
+        good.put("field", "x");
+        good.put("type", "y");
+        Map<String, Object> config2 = new HashMap<>();
+        config2.put("rules", Collections.singletonList(good));
+        Assertions.assertDoesNotThrow(() -> validate(config2, rule));
+    }
+
+    @Test
+    public void testExtensionAndExtensionChain() {
+        ConditionExtension<Integer> positiveExt =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be positive";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value > 0;
+                    }
+                };
+        ConditionExtension<Integer> evenExt =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be even";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value % 2 == 0;
+                    }
+                };
+        Condition<Integer> combined =
+                Conditions.extension(EXT_PORT, positiveExt)
+                        .and(Conditions.extension(EXT_PORT, evenExt));
+        OptionRule rule = OptionRule.builder().required(EXT_PORT, 
combined).build();
+
+        Map<String, Object> pass = new HashMap<>();
+        pass.put("ext.port", 80);
+        Assertions.assertDoesNotThrow(() -> validate(pass, rule));
+
+        // positive but odd -> fail
+        Map<String, Object> failOdd = new HashMap<>();
+        failOdd.put("ext.port", 81);
+        assertThrows(OptionValidationException.class, () -> validate(failOdd, 
rule));
+
+        // even but negative -> fail
+        Map<String, Object> failNeg = new HashMap<>();
+        failNeg.put("ext.port", -2);
+        assertThrows(OptionValidationException.class, () -> validate(failNeg, 
rule));
+    }
+
+    @Test
+    public void testExtensionOrExtensionChain() {
+        ConditionExtension<Integer> wellKnownExt =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "well-known port (1-1023)";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value >= 1 && value <= 1023;
+                    }
+                };
+        ConditionExtension<Integer> highPortExt =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "high port (49152-65535)";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value >= 49152 && value <= 
65535;
+                    }
+                };
+        Condition<Integer> combined =
+                Conditions.extension(EXT_PORT, wellKnownExt)
+                        .or(Conditions.extension(EXT_PORT, highPortExt));
+        OptionRule rule = OptionRule.builder().required(EXT_PORT, 
combined).build();
+
+        Map<String, Object> passWellKnown = new HashMap<>();
+        passWellKnown.put("ext.port", 443);
+        Assertions.assertDoesNotThrow(() -> validate(passWellKnown, rule));
+
+        Map<String, Object> passHigh = new HashMap<>();
+        passHigh.put("ext.port", 50000);
+        Assertions.assertDoesNotThrow(() -> validate(passHigh, rule));
+
+        // middle range -> fail both
+        Map<String, Object> fail = new HashMap<>();
+        fail.put("ext.port", 8080);
+        assertThrows(OptionValidationException.class, () -> validate(fail, 
rule));
+    }
+
+    @Test
+    public void testExtensionWithExclusiveOptional() {
+        ConditionExtension<String> patternExt =
+                new ConditionExtension<String>() {
+                    @Override
+                    public String description() {
+                        return "must start with a letter";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, String 
value) {
+                        return value != null
+                                && !value.isEmpty()
+                                && Character.isLetter(value.charAt(0));
+                    }
+                };
+        OptionRule rule =
+                OptionRule.builder()
+                        .exclusive(TEST_TOPIC_PATTERN, TEST_TOPIC)
+                        .optional(
+                                TEST_TOPIC_PATTERN,
+                                Conditions.extension(TEST_TOPIC_PATTERN, 
patternExt))
+                        .optional(TEST_TOPIC, notEmpty(TEST_TOPIC))
+                        .build();
+
+        // neither present -> fails exclusive
+        Map<String, Object> config1 = new HashMap<>();
+        assertThrows(OptionValidationException.class, () -> validate(config1, 
rule));
+
+        // pattern present with valid value -> pass
+        Map<String, Object> config2 = new HashMap<>();
+        config2.put(TEST_TOPIC_PATTERN.key(), "topic.*");
+        Assertions.assertDoesNotThrow(() -> validate(config2, rule));
+
+        // pattern present but starts with digit -> fails extension
+        Map<String, Object> config3 = new HashMap<>();
+        config3.put(TEST_TOPIC_PATTERN.key(), "123topic");
+        assertThrows(OptionValidationException.class, () -> validate(config3, 
rule));
+
+        // both present -> fails exclusive
+        Map<String, Object> config4 = new HashMap<>();
+        config4.put(TEST_TOPIC_PATTERN.key(), "topic.*");
+        config4.put(TEST_TOPIC.key(), Collections.singletonList("t1"));
+        assertThrows(OptionValidationException.class, () -> validate(config4, 
rule));
+
+        // topic present with valid list -> pass
+        Map<String, Object> config5 = new HashMap<>();
+        config5.put(TEST_TOPIC.key(), Arrays.asList("t1", "t2"));
+        Assertions.assertDoesNotThrow(() -> validate(config5, rule));
+    }
+
+    @Test
+    public void testExtensionWithBundledOptional() {
+        ConditionExtension<String> patternExt =
+                new ConditionExtension<String>() {
+                    @Override
+                    public String description() {
+                        return "must contain a wildcard";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, String 
value) {
+                        return value != null && value.contains("*");
+                    }
+                };
+        OptionRule rule =
+                OptionRule.builder()
+                        .bundled(TEST_TOPIC_PATTERN, TEST_TOPIC)
+                        .optional(
+                                TEST_TOPIC_PATTERN,
+                                Conditions.extension(TEST_TOPIC_PATTERN, 
patternExt))
+                        .optional(TEST_TOPIC, notEmpty(TEST_TOPIC))
+                        .build();
+
+        // neither present -> pass (bundled group absent)
+        Map<String, Object> config1 = new HashMap<>();
+        Assertions.assertDoesNotThrow(() -> validate(config1, rule));
+
+        // both present with valid values -> pass
+        Map<String, Object> config2 = new HashMap<>();
+        config2.put(TEST_TOPIC_PATTERN.key(), "topic.*");
+        config2.put(TEST_TOPIC.key(), Collections.singletonList("t1"));
+        Assertions.assertDoesNotThrow(() -> validate(config2, rule));
+
+        // only one present -> fails bundled
+        Map<String, Object> config3 = new HashMap<>();
+        config3.put(TEST_TOPIC_PATTERN.key(), "topic.*");
+        assertThrows(OptionValidationException.class, () -> validate(config3, 
rule));
+
+        // both present but pattern has no wildcard -> fails extension
+        Map<String, Object> config4 = new HashMap<>();
+        config4.put(TEST_TOPIC_PATTERN.key(), "topic-fixed");
+        config4.put(TEST_TOPIC.key(), Collections.singletonList("t1"));
+        assertThrows(OptionValidationException.class, () -> validate(config4, 
rule));
+
+        // both present but topic is empty -> fails notEmpty
+        Map<String, Object> config5 = new HashMap<>();
+        config5.put(TEST_TOPIC_PATTERN.key(), "topic.*");
+        config5.put(TEST_TOPIC.key(), Collections.emptyList());
+        assertThrows(OptionValidationException.class, () -> validate(config5, 
rule));
+    }
 }
diff --git 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/OptionRuleTest.java
 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/OptionRuleTest.java
index 1c3db5bdfc..38b9a47adc 100644
--- 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/OptionRuleTest.java
+++ 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/OptionRuleTest.java
@@ -22,6 +22,7 @@ import 
org.apache.seatunnel.shade.com.fasterxml.jackson.core.type.TypeReference;
 import org.apache.seatunnel.api.configuration.Option;
 import org.apache.seatunnel.api.configuration.OptionTest;
 import org.apache.seatunnel.api.configuration.Options;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -350,6 +351,35 @@ public class OptionRuleTest {
                                         TEST_TOPIC_PATTERN, 
Conditions.notBlank(TEST_TOPIC_PATTERN))
                                 .build();
         assertThrows(OptionValidationException.class, executable);
+
+        // test extension condition builds correctly
+        ConditionExtension<Integer> positiveExt =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be positive";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value > 0;
+                    }
+                };
+        OptionRule extRule =
+                OptionRule.builder()
+                        .required(TEST_PORTS)
+                        .optional(TEST_NUM, Conditions.extension(TEST_NUM, 
positiveExt))
+                        .build();
+        Assertions.assertNotNull(extRule);
+        assertEquals(1, extRule.getValueConstraints().size());
+        assertEquals(
+                ConditionOperator.EXTENSION, 
extRule.getValueConstraints().get(0).getOperator());
+
+        // test extension with null extension throws
+        assertThrows(IllegalArgumentException.class, () -> 
Conditions.extension(TEST_NUM, null));
+
+        // test extension with null option throws
+        assertThrows(IllegalArgumentException.class, () -> 
Conditions.extension(null, positiveExt));
     }
 
     @Test
diff --git 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java
 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java
index 69d6f78b6b..ff47c66aeb 100644
--- 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java
+++ 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java
@@ -420,10 +420,14 @@ public class MetadataExportCommand implements 
Command<MetadataExportCommandArgs>
         }
         ObjectNode node = mapper.createObjectNode();
         node.put("key", condition.getOption().key());
-        if (condition.getExpectValue() != null) {
-            node.put("expectValue", 
String.valueOf(condition.getExpectValue()));
-        }
         ConditionOperator op = condition.getOperator();
+        Object expectValue = condition.getExpectValue();
+        if (op == ConditionOperator.EXTENSION && condition.getExtension() != 
null) {
+            expectValue = condition.getExtension().description();
+        }
+        if (expectValue != null) {
+            node.put("expectValue", String.valueOf(expectValue));
+        }
         if (op != null) {
             node.put("conditionOperator", op.name());
             node.put("conditionOperatorCategory", op.getCategory().name());
diff --git 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommandTest.java
 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommandTest.java
new file mode 100644
index 0000000000..19eb432fb7
--- /dev/null
+++ 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommandTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.core.starter.seatunnel.command;
+
+import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode;
+import 
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.seatunnel.api.common.PluginIdentifier;
+import org.apache.seatunnel.api.configuration.Option;
+import org.apache.seatunnel.api.configuration.Options;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+import org.apache.seatunnel.api.configuration.util.Conditions;
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.common.constants.PluginType;
+import 
org.apache.seatunnel.core.starter.seatunnel.args.MetadataExportCommandArgs;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MetadataExportCommandTest {
+
+    @Test
+    void shouldExportExtensionConditionDescription() throws Exception {
+        Option<Integer> port =
+                
Options.key("port").intType().noDefaultValue().withDescription("Port number");
+        ConditionExtension<Integer> portRangeExtension =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be between 1 and 65535";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value >= 1 && value <= 65535;
+                    }
+                };
+        OptionRule optionRule =
+                OptionRule.builder()
+                        .required(port, Conditions.extension(port, 
portRangeExtension))
+                        .build();
+
+        MetadataExportCommand command = new MetadataExportCommand(new 
MetadataExportCommandArgs());
+        Method exportConnector =
+                MetadataExportCommand.class.getDeclaredMethod(
+                        "exportConnector",
+                        PluginIdentifier.class,
+                        OptionRule.class,
+                        PluginType.class);
+        exportConnector.setAccessible(true);
+
+        ObjectNode connectorNode =
+                (ObjectNode)
+                        exportConnector.invoke(
+                                command,
+                                PluginIdentifier.of("seatunnel", "source", 
"ExtensionSource"),
+                                optionRule,
+                                PluginType.SOURCE);
+
+        JsonNode valueConstraint = 
connectorNode.get("valueConstraints").get(0);
+        assertTrue(
+                valueConstraint.get("expression").asText().contains("must be 
between 1 and 65535"));
+
+        JsonNode conditionTree = valueConstraint.get("conditionTree");
+        assertEquals("port", conditionTree.get("key").asText());
+        assertEquals("must be between 1 and 65535", 
conditionTree.get("expectValue").asText());
+        assertEquals("extension", 
conditionTree.get("compareOperator").asText());
+        assertEquals("EXTENSION", 
conditionTree.get("conditionOperator").asText());
+        assertEquals("EXTENSION", 
conditionTree.get("conditionOperatorCategory").asText());
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java
index 002a953003..a2f8c7ce57 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java
@@ -282,9 +282,13 @@ public class OptionRulesService extends BaseService {
                         : null;
         String conditionOperator = (op != null) ? op.name() : null;
         String conditionOperatorCategory = (op != null) ? 
op.getCategory().name() : null;
+        Object expectValue = condition.getExpectValue();
+        if (op == ConditionOperator.EXTENSION && condition.getExtension() != 
null) {
+            expectValue = condition.getExtension().description();
+        }
         return new OptionRuleResponse.ConditionNode(
                 toOptionMetadata(condition.getOption()),
-                condition.getExpectValue(),
+                expectValue,
                 compareOperatorSymbol,
                 compareOptionMeta,
                 conditionOperator,
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java
index 151c3ec3e9..ac5c7bd4ab 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java
@@ -20,7 +20,9 @@ package org.apache.seatunnel.engine.server.rest.service;
 import org.apache.seatunnel.api.common.PluginIdentifier;
 import org.apache.seatunnel.api.configuration.Option;
 import org.apache.seatunnel.api.configuration.Options;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.configuration.SingleChoiceOption;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
 import org.apache.seatunnel.api.configuration.util.Conditions;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.engine.server.rest.response.OptionRuleResponse;
@@ -360,6 +362,47 @@ class OptionRulesServiceTest {
         assertEquals("end_ts", tree.getCompareOption().getKey());
     }
 
+    @Test
+    void shouldPreserveExtensionConstraintMetadata() {
+        Option<Integer> port =
+                
Options.key("port").intType().noDefaultValue().withDescription("Port number");
+        ConditionExtension<Integer> portRangeExtension =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be between 1 and 65535";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value) {
+                        return value != null && value >= 1 && value <= 65535;
+                    }
+                };
+
+        OptionRule optionRule =
+                OptionRule.builder()
+                        .required(port, Conditions.extension(port, 
portRangeExtension))
+                        .build();
+
+        OptionRuleResponse response =
+                service.buildResponse(
+                        PluginIdentifier.of("seatunnel", "source", 
"ExtensionSource"), optionRule);
+
+        List<OptionRuleResponse.ValueConstraintMetadata> constraints =
+                response.getOptionRule().getValueConstraints();
+        assertEquals(1, constraints.size());
+
+        OptionRuleResponse.ValueConstraintMetadata constraint = 
constraints.get(0);
+        assertTrue(constraint.getExpression().contains("must be between 1 and 
65535"));
+
+        OptionRuleResponse.ConditionNode tree = constraint.getConditionTree();
+        assertNotNull(tree);
+        assertEquals("must be between 1 and 65535", tree.getExpectValue());
+        assertEquals("extension", tree.getCompareOperator());
+        assertEquals("EXTENSION", tree.getConditionOperator());
+        assertEquals("EXTENSION", tree.getConditionOperatorCategory());
+    }
+
     private enum AuthMode {
         PASSWORD,
         TOKEN

Reply via email to