feat: add plugin graph validation and compilation (#8008)

Implement the graph DTO model and the validation/compilation core for the
optional DAG plugin orchestration defined by the contract in #7994.

New DTOs (shenyu-common):
- GraphData / GraphNodeData / GraphBranchData
- GraphNodeTypeEnum (PLUGIN / CHOICE / END)

New graph package (org.apache.shenyu.common.graph):
- GraphLimits: structural limits (nodes, out-degree, conditions, path length)
- GraphValidationException: fail-closed, carries the violation list
- GraphPlan: immutable compilation artifact (nodesById + successors)
- GraphTopology: shared successor/type helpers
- GraphValidator: cycle / reachability / reference / CHOICE determinism /
  limits / missing-plugin (fail closed) / duplicate-path checks
- GraphCompiler: validateOrThrow then build the immutable GraphPlan
- LegacyChainImporter: import a linear plugin chain into an equivalent graph

56 unit tests (JUnit5 + Hamcrest) cover all of the above; checkstyle clean
(including test sources) and Apache RAT passes.

This branch is stacked on the #7994 contract branch; the compiler matches
that contract exactly.

Fixes #7995
Refs #7993

Co-authored-by: Luke Haochao Zhuang <LukeHaochao.Zhuang@starbucks.cn>
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java
new file mode 100644
index 0000000..f11488e
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphBranchData.java
@@ -0,0 +1,155 @@
+/*
+ * 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.shenyu.common.dto;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A single ordered branch of a {@code CHOICE} graph node.
+ *
+ * <p>A branch reuses the existing {@link ConditionData} model so operators author conditions exactly
+ * as they do for selectors/rules. Selection is deterministic: branches are evaluated in declaration
+ * order and the first branch whose conditions match (under {@code matchMode}) wins; when no branch
+ * matches the owning node's {@code defaultTarget} is used. See {@code docs/plugin-graph-orchestration.md}
+ * (issue #7994) sections 4 and 6.</p>
+ *
+ * @since 2.7.2
+ */
+public class GraphBranchData {
+
+    /**
+     * the branch conditions, evaluated under {@code matchMode}.
+     */
+    private List<ConditionData> conditions;
+
+    /**
+     * match mode of {@code conditions}.
+     * {@linkplain org.apache.shenyu.common.enums.MatchModeEnum} (0 and, 1 or).
+     */
+    private Integer matchMode;
+
+    /**
+     * the target node id selected when this branch matches.
+     */
+    private String target;
+
+    /**
+     * no args constructor.
+     */
+    public GraphBranchData() {
+    }
+
+    /**
+     * all args constructor.
+     *
+     * @param conditions the branch conditions
+     * @param matchMode  the condition match mode
+     * @param target     the target node id
+     */
+    public GraphBranchData(final List<ConditionData> conditions, final Integer matchMode, final String target) {
+        this.conditions = conditions;
+        this.matchMode = matchMode;
+        this.target = target;
+    }
+
+    /**
+     * get conditions.
+     *
+     * @return conditions
+     */
+    public List<ConditionData> getConditions() {
+        return conditions;
+    }
+
+    /**
+     * set conditions.
+     *
+     * @param conditions conditions
+     */
+    public void setConditions(final List<ConditionData> conditions) {
+        this.conditions = conditions;
+    }
+
+    /**
+     * get matchMode.
+     *
+     * @return matchMode
+     */
+    public Integer getMatchMode() {
+        return matchMode;
+    }
+
+    /**
+     * set matchMode.
+     *
+     * @param matchMode matchMode
+     */
+    public void setMatchMode(final Integer matchMode) {
+        this.matchMode = matchMode;
+    }
+
+    /**
+     * get target.
+     *
+     * @return target
+     */
+    public String getTarget() {
+        return target;
+    }
+
+    /**
+     * set target.
+     *
+     * @param target target
+     */
+    public void setTarget(final String target) {
+        this.target = target;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        GraphBranchData that = (GraphBranchData) o;
+        return Objects.equals(conditions, that.conditions) && Objects.equals(matchMode, that.matchMode)
+                && Objects.equals(target, that.target);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(conditions, matchMode, target);
+    }
+
+    @Override
+    public String toString() {
+        return "GraphBranchData{"
+                + "conditions="
+                + conditions
+                + ", matchMode="
+                + matchMode
+                + ", target='"
+                + target
+                + '\''
+                + '}';
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java
new file mode 100644
index 0000000..624509f
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphData.java
@@ -0,0 +1,270 @@
+/*
+ * 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.shenyu.common.dto;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A versioned plugin orchestration graph definition.
+ *
+ * <p>This is the editable, syncable source-of-truth for a graph; a published {@code revision} is
+ * immutable and is compiled into a request-path-free plan by the graph compiler (issue #7995). The
+ * field semantics are fixed by {@code docs/plugin-graph-orchestration.md} (issue #7994) section 4.</p>
+ *
+ * @since 2.7.2
+ */
+public class GraphData {
+
+    /**
+     * contract version of this schema; validators reject unknown majors.
+     */
+    private int schemaVersion;
+
+    /**
+     * the namespace this graph is bound to.
+     */
+    private String namespaceId;
+
+    /**
+     * optional explicit deployment/group id; else namespace-wide.
+     */
+    private String deploymentBinding;
+
+    /**
+     * stable identity across revisions.
+     */
+    private String graphId;
+
+    /**
+     * monotonic per {@code graphId}; activation ordering key.
+     */
+    private long revision;
+
+    /**
+     * hash of the normalized node/edge content; change detection and idempotent sync.
+     */
+    private String contentHash;
+
+    /**
+     * the single entry node id; must reference a node in {@code nodes}.
+     */
+    private String entryNodeId;
+
+    /**
+     * the graph vertices; non-empty with unique node ids.
+     */
+    private List<GraphNodeData> nodes;
+
+    /**
+     * no args constructor.
+     */
+    public GraphData() {
+    }
+
+    /**
+     * get schemaVersion.
+     *
+     * @return schemaVersion
+     */
+    public int getSchemaVersion() {
+        return schemaVersion;
+    }
+
+    /**
+     * set schemaVersion.
+     *
+     * @param schemaVersion schemaVersion
+     */
+    public void setSchemaVersion(final int schemaVersion) {
+        this.schemaVersion = schemaVersion;
+    }
+
+    /**
+     * get namespaceId.
+     *
+     * @return namespaceId
+     */
+    public String getNamespaceId() {
+        return namespaceId;
+    }
+
+    /**
+     * set namespaceId.
+     *
+     * @param namespaceId namespaceId
+     */
+    public void setNamespaceId(final String namespaceId) {
+        this.namespaceId = namespaceId;
+    }
+
+    /**
+     * get deploymentBinding.
+     *
+     * @return deploymentBinding
+     */
+    public String getDeploymentBinding() {
+        return deploymentBinding;
+    }
+
+    /**
+     * set deploymentBinding.
+     *
+     * @param deploymentBinding deploymentBinding
+     */
+    public void setDeploymentBinding(final String deploymentBinding) {
+        this.deploymentBinding = deploymentBinding;
+    }
+
+    /**
+     * get graphId.
+     *
+     * @return graphId
+     */
+    public String getGraphId() {
+        return graphId;
+    }
+
+    /**
+     * set graphId.
+     *
+     * @param graphId graphId
+     */
+    public void setGraphId(final String graphId) {
+        this.graphId = graphId;
+    }
+
+    /**
+     * get revision.
+     *
+     * @return revision
+     */
+    public long getRevision() {
+        return revision;
+    }
+
+    /**
+     * set revision.
+     *
+     * @param revision revision
+     */
+    public void setRevision(final long revision) {
+        this.revision = revision;
+    }
+
+    /**
+     * get contentHash.
+     *
+     * @return contentHash
+     */
+    public String getContentHash() {
+        return contentHash;
+    }
+
+    /**
+     * set contentHash.
+     *
+     * @param contentHash contentHash
+     */
+    public void setContentHash(final String contentHash) {
+        this.contentHash = contentHash;
+    }
+
+    /**
+     * get entryNodeId.
+     *
+     * @return entryNodeId
+     */
+    public String getEntryNodeId() {
+        return entryNodeId;
+    }
+
+    /**
+     * set entryNodeId.
+     *
+     * @param entryNodeId entryNodeId
+     */
+    public void setEntryNodeId(final String entryNodeId) {
+        this.entryNodeId = entryNodeId;
+    }
+
+    /**
+     * get nodes.
+     *
+     * @return nodes
+     */
+    public List<GraphNodeData> getNodes() {
+        return nodes;
+    }
+
+    /**
+     * set nodes.
+     *
+     * @param nodes nodes
+     */
+    public void setNodes(final List<GraphNodeData> nodes) {
+        this.nodes = nodes;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        GraphData that = (GraphData) o;
+        return schemaVersion == that.schemaVersion && revision == that.revision
+                && Objects.equals(namespaceId, that.namespaceId) && Objects.equals(deploymentBinding, that.deploymentBinding)
+                && Objects.equals(graphId, that.graphId) && Objects.equals(contentHash, that.contentHash)
+                && Objects.equals(entryNodeId, that.entryNodeId) && Objects.equals(nodes, that.nodes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(schemaVersion, namespaceId, deploymentBinding, graphId, revision, contentHash, entryNodeId, nodes);
+    }
+
+    @Override
+    public String toString() {
+        return "GraphData{"
+                + "schemaVersion="
+                + schemaVersion
+                + ", namespaceId='"
+                + namespaceId
+                + '\''
+                + ", deploymentBinding='"
+                + deploymentBinding
+                + '\''
+                + ", graphId='"
+                + graphId
+                + '\''
+                + ", revision="
+                + revision
+                + ", contentHash='"
+                + contentHash
+                + '\''
+                + ", entryNodeId='"
+                + entryNodeId
+                + '\''
+                + ", nodes="
+                + nodes
+                + '}';
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java
new file mode 100644
index 0000000..935b701
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/GraphNodeData.java
@@ -0,0 +1,254 @@
+/*
+ * 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.shenyu.common.dto;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A vertex of a plugin orchestration graph.
+ *
+ * <p>The {@code type} field is the canonical name of
+ * {@linkplain org.apache.shenyu.common.enums.GraphNodeTypeEnum} ({@code PLUGIN}, {@code CHOICE} or
+ * {@code END}). Field applicability per type mirrors {@code docs/plugin-graph-orchestration.md}
+ * (issue #7994) section 4:</p>
+ * <ul>
+ *     <li>{@code PLUGIN}: binds {@code pluginName} and has exactly one successor {@code next};
+ *     {@code required} marks a mandatory dependency that fails closed.</li>
+ *     <li>{@code CHOICE}: ordered {@code conditions} branches (first match wins) plus a mandatory
+ *     {@code defaultTarget}.</li>
+ *     <li>{@code END}: terminal; no successor.</li>
+ * </ul>
+ *
+ * @since 2.7.2
+ */
+public class GraphNodeData {
+
+    /**
+     * unique node id within the graph.
+     */
+    private String nodeId;
+
+    /**
+     * node type.
+     * {@linkplain org.apache.shenyu.common.enums.GraphNodeTypeEnum}.
+     */
+    private String type;
+
+    /**
+     * the bound plugin name (PLUGIN nodes only).
+     * {@linkplain org.apache.shenyu.common.enums.PluginEnum#getName()}.
+     */
+    private String pluginName;
+
+    /**
+     * the single successor node id (PLUGIN nodes only).
+     */
+    private String next;
+
+    /**
+     * ordered branches, first match wins (CHOICE nodes only).
+     */
+    private List<GraphBranchData> conditions;
+
+    /**
+     * mandatory fallback target when no branch matches (CHOICE nodes only).
+     */
+    private String defaultTarget;
+
+    /**
+     * whether the bound plugin is a mandatory dependency that fails closed (PLUGIN nodes only).
+     */
+    private boolean required;
+
+    /**
+     * no args constructor.
+     */
+    public GraphNodeData() {
+    }
+
+    /**
+     * get nodeId.
+     *
+     * @return nodeId
+     */
+    public String getNodeId() {
+        return nodeId;
+    }
+
+    /**
+     * set nodeId.
+     *
+     * @param nodeId nodeId
+     */
+    public void setNodeId(final String nodeId) {
+        this.nodeId = nodeId;
+    }
+
+    /**
+     * get type.
+     *
+     * @return type
+     */
+    public String getType() {
+        return type;
+    }
+
+    /**
+     * set type.
+     *
+     * @param type type
+     */
+    public void setType(final String type) {
+        this.type = type;
+    }
+
+    /**
+     * get pluginName.
+     *
+     * @return pluginName
+     */
+    public String getPluginName() {
+        return pluginName;
+    }
+
+    /**
+     * set pluginName.
+     *
+     * @param pluginName pluginName
+     */
+    public void setPluginName(final String pluginName) {
+        this.pluginName = pluginName;
+    }
+
+    /**
+     * get next.
+     *
+     * @return next
+     */
+    public String getNext() {
+        return next;
+    }
+
+    /**
+     * set next.
+     *
+     * @param next next
+     */
+    public void setNext(final String next) {
+        this.next = next;
+    }
+
+    /**
+     * get conditions.
+     *
+     * @return conditions
+     */
+    public List<GraphBranchData> getConditions() {
+        return conditions;
+    }
+
+    /**
+     * set conditions.
+     *
+     * @param conditions conditions
+     */
+    public void setConditions(final List<GraphBranchData> conditions) {
+        this.conditions = conditions;
+    }
+
+    /**
+     * get defaultTarget.
+     *
+     * @return defaultTarget
+     */
+    public String getDefaultTarget() {
+        return defaultTarget;
+    }
+
+    /**
+     * set defaultTarget.
+     *
+     * @param defaultTarget defaultTarget
+     */
+    public void setDefaultTarget(final String defaultTarget) {
+        this.defaultTarget = defaultTarget;
+    }
+
+    /**
+     * is required.
+     *
+     * @return required
+     */
+    public boolean isRequired() {
+        return required;
+    }
+
+    /**
+     * set required.
+     *
+     * @param required required
+     */
+    public void setRequired(final boolean required) {
+        this.required = required;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        GraphNodeData that = (GraphNodeData) o;
+        return required == that.required && Objects.equals(nodeId, that.nodeId) && Objects.equals(type, that.type)
+                && Objects.equals(pluginName, that.pluginName) && Objects.equals(next, that.next)
+                && Objects.equals(conditions, that.conditions) && Objects.equals(defaultTarget, that.defaultTarget);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(nodeId, type, pluginName, next, conditions, defaultTarget, required);
+    }
+
+    @Override
+    public String toString() {
+        return "GraphNodeData{"
+                + "nodeId='"
+                + nodeId
+                + '\''
+                + ", type='"
+                + type
+                + '\''
+                + ", pluginName='"
+                + pluginName
+                + '\''
+                + ", next='"
+                + next
+                + '\''
+                + ", conditions="
+                + conditions
+                + ", defaultTarget='"
+                + defaultTarget
+                + '\''
+                + ", required="
+                + required
+                + '}';
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java
new file mode 100644
index 0000000..5be41f8
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/GraphNodeTypeEnum.java
@@ -0,0 +1,93 @@
+/*
+ * 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.shenyu.common.enums;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The type of a node in a plugin orchestration graph.
+ *
+ * <p>See {@code docs/plugin-graph-orchestration.md} (issue #7994) for the node semantics and
+ * {@code docs/plugin-graph-compatibility-audit.md} for the placement rules derived per plugin.</p>
+ *
+ * @since 2.7.2
+ */
+public enum GraphNodeTypeEnum {
+
+    /**
+     * Binds one installed plugin by name and has exactly one successor.
+     */
+    PLUGIN("PLUGIN"),
+
+    /**
+     * Evaluates ordered branches, first match wins, with a mandatory default target.
+     */
+    CHOICE("CHOICE"),
+
+    /**
+     * Terminal node; completes the traversal.
+     */
+    END("END");
+
+    private final String name;
+
+    /**
+     * all args constructor.
+     *
+     * @param name the canonical name persisted in {@code GraphNodeData#type}
+     */
+    GraphNodeTypeEnum(final String name) {
+        this.name = name;
+    }
+
+    /**
+     * get name.
+     *
+     * @return name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Resolve a graph node type from its canonical name.
+     *
+     * @param name the persisted type name
+     * @return the matching enum, or empty when the name is null or unknown
+     */
+    public static Optional<GraphNodeTypeEnum> fromName(final String name) {
+        if (Objects.isNull(name)) {
+            return Optional.empty();
+        }
+        return Arrays.stream(values())
+                .filter(type -> type.name.equals(name))
+                .findFirst();
+    }
+
+    /**
+     * Whether the given name is a canonical graph node type name.
+     *
+     * @param name the persisted type name
+     * @return true when the name maps to a known type
+     */
+    public static boolean isValidName(final String name) {
+        return fromName(name).isPresent();
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java
new file mode 100644
index 0000000..1207263
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphCompiler.java
@@ -0,0 +1,68 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Compiles a validated {@link GraphData} revision into an immutable {@link GraphPlan}.
+ *
+ * <p>Compilation is fail-closed: the graph is validated first and a {@link GraphValidationException}
+ * aborts the compile, so an invalid graph never produces a plan (contract section 13 forbids any
+ * fallback). On success every node's successors are pre-resolved once, so the GRAPH executor
+ * (issue #7996) traverses the plan without re-deriving topology per request.</p>
+ *
+ * @since 2.7.2
+ */
+public final class GraphCompiler {
+
+    private final GraphValidator validator;
+
+    /**
+     * Instantiates a new GraphCompiler.
+     *
+     * @param validator the validator used to fail closed on invalid graphs
+     */
+    public GraphCompiler(final GraphValidator validator) {
+        this.validator = Objects.requireNonNull(validator, "validator");
+    }
+
+    /**
+     * Compile a graph definition into an immutable plan.
+     *
+     * @param graph the graph definition to compile
+     * @return the compiled, immutable {@link GraphPlan}
+     * @throws GraphValidationException when the graph fails validation
+     */
+    public GraphPlan compile(final GraphData graph) {
+        validator.validateOrThrow(graph);
+        final Map<String, GraphNodeData> nodesById = new LinkedHashMap<>();
+        final Map<String, List<String>> successors = new LinkedHashMap<>();
+        for (GraphNodeData node : graph.getNodes()) {
+            nodesById.put(node.getNodeId(), node);
+            successors.put(node.getNodeId(), GraphTopology.successorsOf(node));
+        }
+        return new GraphPlan(graph, nodesById, successors);
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java
new file mode 100644
index 0000000..1a488ce
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphLimits.java
@@ -0,0 +1,178 @@
+/*
+ * 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.shenyu.common.graph;
+
+/**
+ * The resource limits applied when validating and compiling a plugin orchestration graph.
+ *
+ * <p>Defaults are conservative and configurable; they bound compile cost, plan memory,
+ * retained-revision memory and internal-dispatch recursion. The values mirror the limit table in
+ * {@code docs/plugin-graph-orchestration.md} (issue #7994) section 8.</p>
+ *
+ * @since 2.7.2
+ */
+public final class GraphLimits {
+
+    /**
+     * default maximum number of nodes per graph.
+     */
+    public static final int DEFAULT_MAX_NODES = 128;
+
+    /**
+     * default maximum out-degree (CHOICE branches).
+     */
+    public static final int DEFAULT_MAX_OUT_DEGREE = 16;
+
+    /**
+     * default maximum conditions per branch.
+     */
+    public static final int DEFAULT_MAX_CONDITIONS_PER_BRANCH = 32;
+
+    /**
+     * default maximum path length (prefix + branch + suffix).
+     */
+    public static final int DEFAULT_MAX_PATH_LENGTH = 64;
+
+    /**
+     * default maximum fork / internal-dispatch recursion depth.
+     */
+    public static final int DEFAULT_MAX_FORK_RECURSION_DEPTH = 8;
+
+    /**
+     * default maximum retained revisions per graph.
+     */
+    public static final int DEFAULT_MAX_RETAINED_REVISIONS = 8;
+
+    /**
+     * default maximum graphs per namespace.
+     */
+    public static final int DEFAULT_MAX_GRAPHS_PER_NAMESPACE = 32;
+
+    private static final GraphLimits DEFAULT = new GraphLimits(DEFAULT_MAX_NODES, DEFAULT_MAX_OUT_DEGREE,
+            DEFAULT_MAX_CONDITIONS_PER_BRANCH, DEFAULT_MAX_PATH_LENGTH, DEFAULT_MAX_FORK_RECURSION_DEPTH,
+            DEFAULT_MAX_RETAINED_REVISIONS, DEFAULT_MAX_GRAPHS_PER_NAMESPACE);
+
+    private final int maxNodes;
+
+    private final int maxOutDegree;
+
+    private final int maxConditionsPerBranch;
+
+    private final int maxPathLength;
+
+    private final int maxForkRecursionDepth;
+
+    private final int maxRetainedRevisions;
+
+    private final int maxGraphsPerNamespace;
+
+    /**
+     * all args constructor.
+     *
+     * @param maxNodes               maximum number of nodes per graph
+     * @param maxOutDegree           maximum CHOICE branch fan-out
+     * @param maxConditionsPerBranch maximum conditions per branch
+     * @param maxPathLength          maximum root-to-END path length
+     * @param maxForkRecursionDepth  maximum fork / internal-dispatch recursion depth
+     * @param maxRetainedRevisions   maximum retained revisions per graph
+     * @param maxGraphsPerNamespace  maximum graphs per namespace
+     */
+    public GraphLimits(final int maxNodes, final int maxOutDegree, final int maxConditionsPerBranch,
+                       final int maxPathLength, final int maxForkRecursionDepth, final int maxRetainedRevisions,
+                       final int maxGraphsPerNamespace) {
+        this.maxNodes = maxNodes;
+        this.maxOutDegree = maxOutDegree;
+        this.maxConditionsPerBranch = maxConditionsPerBranch;
+        this.maxPathLength = maxPathLength;
+        this.maxForkRecursionDepth = maxForkRecursionDepth;
+        this.maxRetainedRevisions = maxRetainedRevisions;
+        this.maxGraphsPerNamespace = maxGraphsPerNamespace;
+    }
+
+    /**
+     * The shared default limits.
+     *
+     * @return the default {@link GraphLimits}
+     */
+    public static GraphLimits defaults() {
+        return DEFAULT;
+    }
+
+    /**
+     * get maxNodes.
+     *
+     * @return maxNodes
+     */
+    public int getMaxNodes() {
+        return maxNodes;
+    }
+
+    /**
+     * get maxOutDegree.
+     *
+     * @return maxOutDegree
+     */
+    public int getMaxOutDegree() {
+        return maxOutDegree;
+    }
+
+    /**
+     * get maxConditionsPerBranch.
+     *
+     * @return maxConditionsPerBranch
+     */
+    public int getMaxConditionsPerBranch() {
+        return maxConditionsPerBranch;
+    }
+
+    /**
+     * get maxPathLength.
+     *
+     * @return maxPathLength
+     */
+    public int getMaxPathLength() {
+        return maxPathLength;
+    }
+
+    /**
+     * get maxForkRecursionDepth.
+     *
+     * @return maxForkRecursionDepth
+     */
+    public int getMaxForkRecursionDepth() {
+        return maxForkRecursionDepth;
+    }
+
+    /**
+     * get maxRetainedRevisions.
+     *
+     * @return maxRetainedRevisions
+     */
+    public int getMaxRetainedRevisions() {
+        return maxRetainedRevisions;
+    }
+
+    /**
+     * get maxGraphsPerNamespace.
+     *
+     * @return maxGraphsPerNamespace
+     */
+    public int getMaxGraphsPerNamespace() {
+        return maxGraphsPerNamespace;
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java
new file mode 100644
index 0000000..2c829b7
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphPlan.java
@@ -0,0 +1,212 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The compiled, immutable, request-path-free form of a validated {@link GraphData} revision.
+ *
+ * <p>A plan freezes the graph topology: every node is indexed by id and every node's successors are
+ * pre-resolved (a {@code PLUGIN} node has its single {@code next}; a {@code CHOICE} node has its
+ * ordered branch targets followed by its {@code defaultTarget}; an {@code END} node has none). The
+ * GRAPH executor (issue #7996) traverses a plan snapshot without mutating it, so all collections
+ * exposed here are unmodifiable. See {@code docs/plugin-graph-orchestration.md} (issue #7994)
+ * sections 3 and 10.</p>
+ *
+ * @since 2.7.2
+ */
+public final class GraphPlan {
+
+    private final String graphId;
+
+    private final long revision;
+
+    private final String contentHash;
+
+    private final String namespaceId;
+
+    private final String entryNodeId;
+
+    private final Map<String, GraphNodeData> nodesById;
+
+    private final Map<String, List<String>> successors;
+
+    /**
+     * Instantiates a new GraphPlan.
+     *
+     * @param graph      the validated graph definition this plan was compiled from
+     * @param nodesById  node id to node index (copied, exposed unmodifiable)
+     * @param successors node id to resolved successor node ids (copied, exposed unmodifiable)
+     */
+    public GraphPlan(final GraphData graph, final Map<String, GraphNodeData> nodesById,
+                     final Map<String, List<String>> successors) {
+        Objects.requireNonNull(graph, "graph");
+        Objects.requireNonNull(nodesById, "nodesById");
+        Objects.requireNonNull(successors, "successors");
+        this.graphId = graph.getGraphId();
+        this.revision = graph.getRevision();
+        this.contentHash = graph.getContentHash();
+        this.namespaceId = graph.getNamespaceId();
+        this.entryNodeId = graph.getEntryNodeId();
+        this.nodesById = Collections.unmodifiableMap(new LinkedHashMap<>(nodesById));
+        this.successors = freezeSuccessors(successors);
+    }
+
+    private static Map<String, List<String>> freezeSuccessors(final Map<String, List<String>> raw) {
+        Map<String, List<String>> frozen = new LinkedHashMap<>(raw.size());
+        for (Map.Entry<String, List<String>> entry : raw.entrySet()) {
+            frozen.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue())));
+        }
+        return Collections.unmodifiableMap(frozen);
+    }
+
+    /**
+     * get graphId.
+     *
+     * @return graphId
+     */
+    public String getGraphId() {
+        return graphId;
+    }
+
+    /**
+     * get revision.
+     *
+     * @return revision
+     */
+    public long getRevision() {
+        return revision;
+    }
+
+    /**
+     * get contentHash.
+     *
+     * @return contentHash
+     */
+    public String getContentHash() {
+        return contentHash;
+    }
+
+    /**
+     * get namespaceId.
+     *
+     * @return namespaceId
+     */
+    public String getNamespaceId() {
+        return namespaceId;
+    }
+
+    /**
+     * get entryNodeId.
+     *
+     * @return entryNodeId
+     */
+    public String getEntryNodeId() {
+        return entryNodeId;
+    }
+
+    /**
+     * The node bound to the given id.
+     *
+     * @param nodeId the node id
+     * @return the node, or {@code null} when the id is not part of this plan
+     */
+    public GraphNodeData getNode(final String nodeId) {
+        return nodesById.get(nodeId);
+    }
+
+    /**
+     * The resolved successor node ids of the given node, in evaluation order.
+     *
+     * @param nodeId the node id
+     * @return an unmodifiable successor list; empty for an unknown id or an {@code END} node
+     */
+    public List<String> getSuccessors(final String nodeId) {
+        return successors.getOrDefault(nodeId, Collections.emptyList());
+    }
+
+    /**
+     * The unmodifiable set of all node ids in this plan.
+     *
+     * @return the node ids
+     */
+    public Set<String> nodeIds() {
+        return nodesById.keySet();
+    }
+
+    /**
+     * The number of nodes in this plan.
+     *
+     * @return the node count
+     */
+    public int size() {
+        return nodesById.size();
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (Objects.isNull(o) || getClass() != o.getClass()) {
+            return false;
+        }
+        GraphPlan that = (GraphPlan) o;
+        return revision == that.revision && Objects.equals(graphId, that.graphId)
+                && Objects.equals(contentHash, that.contentHash) && Objects.equals(namespaceId, that.namespaceId)
+                && Objects.equals(entryNodeId, that.entryNodeId) && Objects.equals(nodesById, that.nodesById)
+                && Objects.equals(successors, that.successors);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(graphId, revision, contentHash, namespaceId, entryNodeId, nodesById, successors);
+    }
+
+    @Override
+    public String toString() {
+        return "GraphPlan{"
+                + "graphId='"
+                + graphId
+                + '\''
+                + ", revision="
+                + revision
+                + ", contentHash='"
+                + contentHash
+                + '\''
+                + ", namespaceId='"
+                + namespaceId
+                + '\''
+                + ", entryNodeId='"
+                + entryNodeId
+                + '\''
+                + ", nodes="
+                + nodesById.size()
+                + '}';
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java
new file mode 100644
index 0000000..54b58a6
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphTopology.java
@@ -0,0 +1,117 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Static helpers describing the topology of a plugin orchestration graph node.
+ *
+ * <p>Shared by the validator and the compiler so successor resolution and node-type tests are defined
+ * exactly once. Successor resolution is structural only: it never evaluates branch conditions (that is
+ * a request-time concern of the GRAPH executor, issue #7996).</p>
+ *
+ * @since 2.7.2
+ */
+public final class GraphTopology {
+
+    private GraphTopology() {
+    }
+
+    /**
+     * Whether the value is null or blank.
+     *
+     * @param value the string to test
+     * @return true when null or blank
+     */
+    public static boolean isBlank(final String value) {
+        return Objects.isNull(value) || value.isBlank();
+    }
+
+    /**
+     * Whether the node is a {@code PLUGIN} node.
+     *
+     * @param node the node
+     * @return true for a PLUGIN node
+     */
+    public static boolean isPlugin(final GraphNodeData node) {
+        return Objects.nonNull(node) && GraphNodeTypeEnum.PLUGIN.getName().equals(node.getType());
+    }
+
+    /**
+     * Whether the node is a {@code CHOICE} node.
+     *
+     * @param node the node
+     * @return true for a CHOICE node
+     */
+    public static boolean isChoice(final GraphNodeData node) {
+        return Objects.nonNull(node) && GraphNodeTypeEnum.CHOICE.getName().equals(node.getType());
+    }
+
+    /**
+     * Whether the node is an {@code END} node.
+     *
+     * @param node the node
+     * @return true for an END node
+     */
+    public static boolean isEnd(final GraphNodeData node) {
+        return Objects.nonNull(node) && GraphNodeTypeEnum.END.getName().equals(node.getType());
+    }
+
+    /**
+     * The structural successor node ids of a node, in evaluation order.
+     *
+     * <p>A {@code PLUGIN} node yields its single {@code next}; a {@code CHOICE} node yields its
+     * distinct branch targets in declaration order followed by its {@code defaultTarget}; an
+     * {@code END} node yields none.</p>
+     *
+     * @param node the node
+     * @return the successor node ids (never null)
+     */
+    public static List<String> successorsOf(final GraphNodeData node) {
+        if (isPlugin(node)) {
+            return isBlank(node.getNext()) ? Collections.emptyList() : Collections.singletonList(node.getNext());
+        }
+        if (isChoice(node)) {
+            return choiceSuccessors(node);
+        }
+        return Collections.emptyList();
+    }
+
+    private static List<String> choiceSuccessors(final GraphNodeData node) {
+        final List<String> targets = new ArrayList<>();
+        if (Objects.nonNull(node.getConditions())) {
+            for (GraphBranchData branch : node.getConditions()) {
+                if (Objects.nonNull(branch) && !isBlank(branch.getTarget()) && !targets.contains(branch.getTarget())) {
+                    targets.add(branch.getTarget());
+                }
+            }
+        }
+        if (!isBlank(node.getDefaultTarget()) && !targets.contains(node.getDefaultTarget())) {
+            targets.add(node.getDefaultTarget());
+        }
+        return targets;
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.java
new file mode 100644
index 0000000..94f8d56
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidationException.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.shenyu.common.graph;
+
+import org.apache.shenyu.common.exception.ShenyuException;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Thrown when a plugin orchestration graph fails validation.
+ *
+ * <p>Carries the full, ordered list of violations so callers can surface every actionable error at
+ * once (naming the offending node id / plugin name) rather than only the first. Validation is
+ * fail-closed: a graph that cannot be validated is never compiled or activated.</p>
+ *
+ * @since 2.7.2
+ */
+public class GraphValidationException extends ShenyuException {
+
+    private static final long serialVersionUID = -6871045473042167831L;
+
+    private final List<String> violations;
+
+    /**
+     * Instantiates a new GraphValidationException.
+     *
+     * @param violations the ordered, non-empty list of violation messages
+     */
+    public GraphValidationException(final List<String> violations) {
+        super(buildMessage(violations));
+        this.violations = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(violations, "violations")));
+    }
+
+    /**
+     * The ordered, unmodifiable list of violation messages.
+     *
+     * @return the violations
+     */
+    public List<String> getViolations() {
+        return violations;
+    }
+
+    private static String buildMessage(final List<String> violations) {
+        if (Objects.isNull(violations) || violations.isEmpty()) {
+            return "plugin graph validation failed";
+        }
+        return "plugin graph validation failed with " + violations.size() + " violation(s): " + String.join("; ", violations);
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java
new file mode 100644
index 0000000..b7f5230
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/GraphValidator.java
@@ -0,0 +1,407 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validates a {@link GraphData} against the Phase-1 contract before it may be compiled.
+ *
+ * <p>Validation is fail-closed and reports every violation at once (each naming the offending node id
+ * or plugin name). It enforces the rules derived in {@code docs/plugin-graph-compatibility-audit.md}
+ * (issue #7994) section 5: header/schema integrity, unique node ids, resource limits, per-type node
+ * shape, installed-plugin catalog resolution, CHOICE determinism, reference integrity, acyclicity,
+ * reachability, and path-wise checks (bounded length, no plugin executed twice on a path, termination
+ * at an {@code END} node).</p>
+ *
+ * @since 2.7.2
+ */
+public final class GraphValidator {
+
+    /**
+     * the only schema major version accepted in Phase 1.
+     */
+    public static final int SUPPORTED_SCHEMA_VERSION = 1;
+
+    private static final int MAX_ENUMERATED_PATHS = 100000;
+
+    private final GraphLimits limits;
+
+    private final Set<String> installedPlugins;
+
+    /**
+     * Instantiates a new GraphValidator with the default limits.
+     *
+     * @param installedPlugins the names of plugins installed in the runtime catalog
+     */
+    public GraphValidator(final Set<String> installedPlugins) {
+        this(GraphLimits.defaults(), installedPlugins);
+    }
+
+    /**
+     * Instantiates a new GraphValidator.
+     *
+     * @param limits           the resource limits to enforce
+     * @param installedPlugins the names of plugins installed in the runtime catalog
+     */
+    public GraphValidator(final GraphLimits limits, final Set<String> installedPlugins) {
+        this.limits = Objects.requireNonNull(limits, "limits");
+        this.installedPlugins = Objects.requireNonNull(installedPlugins, "installedPlugins");
+    }
+
+    /**
+     * Validate a graph, returning all violations.
+     *
+     * @param graph the graph to validate
+     * @return the ordered, de-duplicated list of violations; empty when the graph is valid
+     */
+    public List<String> validate(final GraphData graph) {
+        final List<String> violations = new ArrayList<>();
+        if (Objects.isNull(graph)) {
+            violations.add("graph must not be null");
+            return violations;
+        }
+        validateHeader(graph, violations);
+        final Map<String, GraphNodeData> byId = indexNodes(graph, violations);
+        if (byId.isEmpty()) {
+            return dedupe(violations);
+        }
+        validateLimits(graph, byId, violations);
+        validateNodeShape(byId, violations);
+        validateChoiceDeterminism(byId, violations);
+        if (validateReferences(graph, byId, violations)) {
+            final boolean acyclic = validateAcyclic(graph, byId, violations);
+            validateReachability(graph, byId, violations);
+            if (acyclic) {
+                validatePaths(graph, byId, violations);
+            }
+        }
+        return dedupe(violations);
+    }
+
+    /**
+     * Validate a graph or throw.
+     *
+     * @param graph the graph to validate
+     * @throws GraphValidationException when the graph has at least one violation
+     */
+    public void validateOrThrow(final GraphData graph) {
+        final List<String> violations = validate(graph);
+        if (!violations.isEmpty()) {
+            throw new GraphValidationException(violations);
+        }
+    }
+
+    private List<String> dedupe(final List<String> violations) {
+        return violations.stream().distinct().collect(Collectors.toList());
+    }
+
+    private void validateHeader(final GraphData graph, final List<String> violations) {
+        if (graph.getSchemaVersion() != SUPPORTED_SCHEMA_VERSION) {
+            violations.add("unsupported schemaVersion " + graph.getSchemaVersion() + ", expected " + SUPPORTED_SCHEMA_VERSION);
+        }
+        if (GraphTopology.isBlank(graph.getGraphId())) {
+            violations.add("graphId must not be blank");
+        }
+        if (GraphTopology.isBlank(graph.getNamespaceId())) {
+            violations.add("namespaceId must not be blank");
+        }
+        if (Objects.isNull(graph.getNodes()) || graph.getNodes().isEmpty()) {
+            violations.add("nodes must not be empty");
+        }
+        if (GraphTopology.isBlank(graph.getEntryNodeId())) {
+            violations.add("entryNodeId must not be blank");
+        }
+    }
+
+    private Map<String, GraphNodeData> indexNodes(final GraphData graph, final List<String> violations) {
+        final Map<String, GraphNodeData> byId = new LinkedHashMap<>();
+        if (Objects.isNull(graph.getNodes())) {
+            return byId;
+        }
+        for (GraphNodeData node : graph.getNodes()) {
+            if (Objects.isNull(node) || GraphTopology.isBlank(node.getNodeId())) {
+                violations.add("every node must have a non-blank nodeId");
+                continue;
+            }
+            if (byId.containsKey(node.getNodeId())) {
+                violations.add("duplicate nodeId '" + node.getNodeId() + "'");
+                continue;
+            }
+            if (!GraphNodeTypeEnum.isValidName(node.getType())) {
+                violations.add("node '" + node.getNodeId() + "' has unknown type '" + node.getType() + "'");
+            }
+            byId.put(node.getNodeId(), node);
+        }
+        return byId;
+    }
+
+    private void validateLimits(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        if (byId.size() > limits.getMaxNodes()) {
+            violations.add("graph '" + graph.getGraphId() + "' has " + byId.size() + " nodes, exceeding max " + limits.getMaxNodes());
+        }
+        for (GraphNodeData node : byId.values()) {
+            if (!GraphTopology.isChoice(node)) {
+                continue;
+            }
+            final int branches = Objects.isNull(node.getConditions()) ? 0 : node.getConditions().size();
+            if (branches > limits.getMaxOutDegree()) {
+                violations.add("CHOICE node '" + node.getNodeId() + "' has " + branches + " branches, exceeding max out-degree " + limits.getMaxOutDegree());
+            }
+            validateBranchConditionCount(node, violations);
+        }
+    }
+
+    private void validateBranchConditionCount(final GraphNodeData node, final List<String> violations) {
+        if (Objects.isNull(node.getConditions())) {
+            return;
+        }
+        for (GraphBranchData branch : node.getConditions()) {
+            if (Objects.isNull(branch)) {
+                continue;
+            }
+            final int conditions = Objects.isNull(branch.getConditions()) ? 0 : branch.getConditions().size();
+            if (conditions > limits.getMaxConditionsPerBranch()) {
+                violations.add("branch to '" + branch.getTarget() + "' on CHOICE node '" + node.getNodeId() + "' has "
+                        + conditions + " conditions, exceeding max " + limits.getMaxConditionsPerBranch());
+            }
+        }
+    }
+
+    private void validateNodeShape(final Map<String, GraphNodeData> byId, final List<String> violations) {
+        for (GraphNodeData node : byId.values()) {
+            if (GraphTopology.isPlugin(node)) {
+                validatePluginNode(node, violations);
+            } else if (GraphTopology.isChoice(node)) {
+                validateChoiceNode(node, violations);
+            } else if (GraphTopology.isEnd(node)) {
+                validateEndNode(node, violations);
+            }
+        }
+    }
+
+    private void validatePluginNode(final GraphNodeData node, final List<String> violations) {
+        if (GraphTopology.isBlank(node.getPluginName())) {
+            violations.add("PLUGIN node '" + node.getNodeId() + "' must bind a pluginName");
+        } else if (!installedPlugins.contains(node.getPluginName())) {
+            violations.add((node.isRequired() ? "required " : "") + "PLUGIN node '" + node.getNodeId()
+                    + "' binds plugin '" + node.getPluginName() + "' which is not installed (fail closed)");
+        }
+        if (GraphTopology.isBlank(node.getNext())) {
+            violations.add("PLUGIN node '" + node.getNodeId() + "' must have exactly one successor 'next'");
+        }
+    }
+
+    private void validateChoiceNode(final GraphNodeData node, final List<String> violations) {
+        if (Objects.isNull(node.getConditions()) || node.getConditions().isEmpty()) {
+            violations.add("CHOICE node '" + node.getNodeId() + "' must declare at least one branch");
+        }
+        if (GraphTopology.isBlank(node.getDefaultTarget())) {
+            violations.add("CHOICE node '" + node.getNodeId() + "' must declare a defaultTarget (selection must be total)");
+        }
+    }
+
+    private void validateEndNode(final GraphNodeData node, final List<String> violations) {
+        if (!GraphTopology.isBlank(node.getNext())) {
+            violations.add("END node '" + node.getNodeId() + "' must not declare a successor 'next'");
+        }
+        if (Objects.nonNull(node.getConditions()) && !node.getConditions().isEmpty()) {
+            violations.add("END node '" + node.getNodeId() + "' must not declare branches");
+        }
+    }
+
+    private void validateChoiceDeterminism(final Map<String, GraphNodeData> byId, final List<String> violations) {
+        for (GraphNodeData node : byId.values()) {
+            if (!GraphTopology.isChoice(node) || Objects.isNull(node.getConditions())) {
+                continue;
+            }
+            final Set<GraphBranchData> seen = new HashSet<>();
+            for (GraphBranchData branch : node.getConditions()) {
+                if (Objects.nonNull(branch) && !seen.add(branch)) {
+                    violations.add("CHOICE node '" + node.getNodeId() + "' declares a duplicate branch to '"
+                            + branch.getTarget() + "' (ambiguous authoring)");
+                }
+            }
+        }
+    }
+
+    private boolean validateReferences(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        boolean ok = true;
+        if (!GraphTopology.isBlank(graph.getEntryNodeId()) && !byId.containsKey(graph.getEntryNodeId())) {
+            violations.add("entryNodeId '" + graph.getEntryNodeId() + "' does not reference a node");
+            ok = false;
+        }
+        for (GraphNodeData node : byId.values()) {
+            if (GraphTopology.isPlugin(node) && !GraphTopology.isBlank(node.getNext()) && !byId.containsKey(node.getNext())) {
+                violations.add("PLUGIN node '" + node.getNodeId() + "' successor 'next' -> unknown node '" + node.getNext() + "'");
+                ok = false;
+            }
+            if (GraphTopology.isChoice(node)) {
+                ok = validateChoiceReferences(node, byId, violations) && ok;
+            }
+        }
+        return ok;
+    }
+
+    private boolean validateChoiceReferences(final GraphNodeData node, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        boolean ok = true;
+        if (Objects.nonNull(node.getConditions())) {
+            for (GraphBranchData branch : node.getConditions()) {
+                if (Objects.isNull(branch) || GraphTopology.isBlank(branch.getTarget()) || !byId.containsKey(branch.getTarget())) {
+                    final String target = Objects.isNull(branch) ? null : branch.getTarget();
+                    violations.add("CHOICE node '" + node.getNodeId() + "' branch target '" + target + "' -> unknown node");
+                    ok = false;
+                }
+            }
+        }
+        if (!GraphTopology.isBlank(node.getDefaultTarget()) && !byId.containsKey(node.getDefaultTarget())) {
+            violations.add("CHOICE node '" + node.getNodeId() + "' defaultTarget '" + node.getDefaultTarget() + "' -> unknown node");
+            ok = false;
+        }
+        return ok;
+    }
+
+    private boolean validateAcyclic(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        final Set<String> visited = new HashSet<>();
+        final Set<String> onStack = new LinkedHashSet<>();
+        boolean acyclic = true;
+        for (String start : byId.keySet()) {
+            if (!visited.contains(start) && hasCycle(start, byId, visited, onStack)) {
+                violations.add("graph '" + graph.getGraphId() + "' contains a cycle reachable from node '" + start + "'");
+                acyclic = false;
+            }
+        }
+        return acyclic;
+    }
+
+    private boolean hasCycle(final String nodeId, final Map<String, GraphNodeData> byId, final Set<String> visited, final Set<String> onStack) {
+        visited.add(nodeId);
+        onStack.add(nodeId);
+        final GraphNodeData node = byId.get(nodeId);
+        if (Objects.nonNull(node)) {
+            for (String next : GraphTopology.successorsOf(node)) {
+                if (onStack.contains(next)) {
+                    return true;
+                }
+                if (!visited.contains(next) && byId.containsKey(next) && hasCycle(next, byId, visited, onStack)) {
+                    return true;
+                }
+            }
+        }
+        onStack.remove(nodeId);
+        return false;
+    }
+
+    private void validateReachability(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        final Set<String> reachable = new HashSet<>();
+        final Deque<String> queue = new ArrayDeque<>();
+        queue.add(graph.getEntryNodeId());
+        while (!queue.isEmpty()) {
+            final String current = queue.poll();
+            if (!reachable.add(current)) {
+                continue;
+            }
+            final GraphNodeData node = byId.get(current);
+            if (Objects.isNull(node)) {
+                continue;
+            }
+            for (String next : GraphTopology.successorsOf(node)) {
+                if (byId.containsKey(next)) {
+                    queue.add(next);
+                }
+            }
+        }
+        for (String nodeId : byId.keySet()) {
+            if (!reachable.contains(nodeId)) {
+                violations.add("node '" + nodeId + "' is not reachable from entryNodeId '" + graph.getEntryNodeId() + "'");
+            }
+        }
+    }
+
+    private void validatePaths(final GraphData graph, final Map<String, GraphNodeData> byId, final List<String> violations) {
+        final int[] pathCount = {0};
+        final boolean[] overflow = {false};
+        walkPaths(graph, graph.getEntryNodeId(), byId, new ArrayList<>(), new ArrayList<>(), pathCount, overflow, violations);
+        if (pathCount[0] == 0) {
+            violations.add("graph '" + graph.getGraphId() + "' has no path terminating at an END node");
+        }
+        if (overflow[0]) {
+            violations.add("graph '" + graph.getGraphId() + "' exceeds the maximum enumerable path count " + MAX_ENUMERATED_PATHS);
+        }
+    }
+
+    private void walkPaths(final GraphData graph, final String nodeId, final Map<String, GraphNodeData> byId,
+                           final List<String> path, final List<String> plugins, final int[] pathCount,
+                           final boolean[] overflow, final List<String> violations) {
+        if (overflow[0] || pathCount[0] > MAX_ENUMERATED_PATHS) {
+            overflow[0] = true;
+            return;
+        }
+        final GraphNodeData node = byId.get(nodeId);
+        if (Objects.isNull(node)) {
+            return;
+        }
+        path.add(nodeId);
+        final boolean tracksPlugin = GraphTopology.isPlugin(node) && !GraphTopology.isBlank(node.getPluginName());
+        if (tracksPlugin) {
+            plugins.add(node.getPluginName());
+        }
+        if (GraphTopology.isEnd(node)) {
+            checkPath(graph, path, plugins, pathCount, violations);
+        } else {
+            for (String next : GraphTopology.successorsOf(node)) {
+                walkPaths(graph, next, byId, path, plugins, pathCount, overflow, violations);
+            }
+        }
+        path.remove(path.size() - 1);
+        if (tracksPlugin) {
+            plugins.remove(plugins.size() - 1);
+        }
+    }
+
+    private void checkPath(final GraphData graph, final List<String> path, final List<String> plugins,
+                           final int[] pathCount, final List<String> violations) {
+        pathCount[0] = pathCount[0] + 1;
+        if (path.size() > limits.getMaxPathLength()) {
+            violations.add("graph '" + graph.getGraphId() + "' has a path of length " + path.size()
+                    + " exceeding max " + limits.getMaxPathLength() + ": " + path);
+        }
+        final Set<String> seen = new HashSet<>();
+        for (String pluginName : plugins) {
+            if (!seen.add(pluginName)) {
+                violations.add("graph '" + graph.getGraphId() + "' path " + path + " executes plugin '"
+                        + pluginName + "' more than once");
+            }
+        }
+    }
+}
diff --git a/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java
new file mode 100644
index 0000000..d8c14c3
--- /dev/null
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/graph/LegacyChainImporter.java
@@ -0,0 +1,162 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+import org.apache.shenyu.common.enums.PluginEnum;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Imports the legacy sorted plugin chain into an equivalent linear {@link GraphData}.
+ *
+ * <p>This gives operators a migration path from the {@code PluginData.sort}-ordered chain to an
+ * explicit graph: the produced graph is a single common prefix of {@code PLUGIN} nodes followed by one
+ * {@code END} node, reproducing legacy execution order exactly. Plugins whose name matches the
+ * mandatory global/auth class (see {@code docs/plugin-graph-compatibility-audit.md}, issue #7994) are
+ * marked {@code required} so the fail-closed semantics of the legacy chain are preserved.</p>
+ *
+ * @since 2.7.2
+ */
+public final class LegacyChainImporter {
+
+    /**
+     * the node id of the terminal END node produced by the importer.
+     */
+    public static final String END_NODE_ID = "end";
+
+    private static final Set<String> MANDATORY_PLUGINS = Set.of(
+            PluginEnum.GLOBAL.getName(),
+            PluginEnum.SIGN.getName(),
+            PluginEnum.JWT.getName(),
+            PluginEnum.OAUTH2.getName(),
+            PluginEnum.CASDOOR.getName(),
+            PluginEnum.WAF.getName(),
+            PluginEnum.KEY_AUTH.getName(),
+            PluginEnum.BASIC_AUTH.getName());
+
+    private LegacyChainImporter() {
+    }
+
+    /**
+     * Import an ordered legacy plugin chain into a linear graph definition.
+     *
+     * <p>Disabled plugins and entries without a name are dropped; the remainder are ordered by
+     * {@link PluginData#getSort()} ascending (nulls last), mirroring the legacy chain ordering.</p>
+     *
+     * @param plugins     the legacy plugins (need not be pre-sorted)
+     * @param namespaceId the namespace to bind the graph to
+     * @param graphId     the stable graph identity
+     * @param revision    the revision number to publish
+     * @return a linear {@link GraphData} equivalent to the legacy chain
+     */
+    public static GraphData importLinearChain(final List<PluginData> plugins, final String namespaceId,
+                                              final String graphId, final long revision) {
+        Objects.requireNonNull(plugins, "plugins");
+        final List<PluginData> ordered = orderEnabled(plugins);
+        final List<GraphNodeData> nodes = new ArrayList<>();
+        for (int i = 0; i < ordered.size(); i++) {
+            nodes.add(pluginNode(ordered.get(i), i, ordered.size()));
+        }
+        nodes.add(endNode());
+        final GraphData graph = new GraphData();
+        graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION);
+        graph.setNamespaceId(namespaceId);
+        graph.setGraphId(graphId);
+        graph.setRevision(revision);
+        graph.setEntryNodeId(ordered.isEmpty() ? END_NODE_ID : nodeId(0));
+        graph.setNodes(nodes);
+        graph.setContentHash(contentHash(graph));
+        return graph;
+    }
+
+    /**
+     * Compute a deterministic content fingerprint of a graph's normalized node/edge content.
+     *
+     * <p>Phase 1 uses a stable hash of the canonicalized node sequence for change detection and
+     * idempotent sync; a stronger digest may replace it without a schema change.</p>
+     *
+     * @param graph the graph whose content to fingerprint
+     * @return the hex content hash
+     */
+    public static String contentHash(final GraphData graph) {
+        final StringBuilder canonical = new StringBuilder();
+        if (Objects.isNull(graph) || Objects.isNull(graph.getNodes())) {
+            return Integer.toHexString(canonical.toString().hashCode());
+        }
+        for (GraphNodeData node : graph.getNodes()) {
+            appendNode(canonical, node);
+        }
+        return Integer.toHexString(canonical.toString().hashCode());
+    }
+
+    private static void appendNode(final StringBuilder canonical, final GraphNodeData node) {
+        canonical.append(node.getNodeId()).append('|')
+                .append(node.getType()).append('|')
+                .append(node.getPluginName()).append('|')
+                .append(node.getNext()).append('|')
+                .append(node.getDefaultTarget()).append('|')
+                .append(node.isRequired()).append(';');
+        if (Objects.isNull(node.getConditions())) {
+            return;
+        }
+        for (GraphBranchData branch : node.getConditions()) {
+            canonical.append(branch.getTarget()).append('=').append(branch.getMatchMode())
+                    .append('=').append(branch.getConditions()).append(',');
+        }
+    }
+
+    private static List<PluginData> orderEnabled(final List<PluginData> plugins) {
+        return plugins.stream()
+                .filter(Objects::nonNull)
+                .filter(plugin -> !Boolean.FALSE.equals(plugin.getEnabled()))
+                .filter(plugin -> !GraphTopology.isBlank(plugin.getName()))
+                .sorted(Comparator.comparingInt(plugin -> Objects.isNull(plugin.getSort()) ? Integer.MAX_VALUE : plugin.getSort()))
+                .collect(Collectors.toList());
+    }
+
+    private static GraphNodeData pluginNode(final PluginData plugin, final int index, final int total) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(nodeId(index));
+        node.setType(GraphNodeTypeEnum.PLUGIN.getName());
+        node.setPluginName(plugin.getName());
+        node.setRequired(MANDATORY_PLUGINS.contains(plugin.getName()));
+        node.setNext(index == total - 1 ? END_NODE_ID : nodeId(index + 1));
+        return node;
+    }
+
+    private static GraphNodeData endNode() {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(END_NODE_ID);
+        node.setType(GraphNodeTypeEnum.END.getName());
+        return node;
+    }
+
+    private static String nodeId(final int index) {
+        return "n" + index;
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java
new file mode 100644
index 0000000..06821d0
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphBranchDataTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.shenyu.common.dto;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Test case for {@link GraphBranchData}.
+ */
+public class GraphBranchDataTest {
+
+    @Test
+    public void testGetterSetter() {
+        final GraphBranchData branch = new GraphBranchData();
+        branch.setConditions(Collections.singletonList(new ConditionData()));
+        branch.setMatchMode(1);
+        branch.setTarget("n1");
+
+        assertThat(branch.getConditions(), hasSize(1));
+        assertThat(branch.getMatchMode(), is(1));
+        assertThat(branch.getTarget(), is("n1"));
+    }
+
+    @Test
+    public void testAllArgsConstructor() {
+        final GraphBranchData branch = new GraphBranchData(Collections.emptyList(), 0, "n2");
+        assertThat(branch.getTarget(), is("n2"));
+        assertThat(branch.getMatchMode(), is(0));
+    }
+
+    @Test
+    public void testEqualsAndHashCode() {
+        final GraphBranchData one = new GraphBranchData(Collections.emptyList(), 0, "n1");
+        final GraphBranchData two = new GraphBranchData(Collections.emptyList(), 0, "n1");
+        final Set<GraphBranchData> set = new HashSet<>();
+        set.add(one);
+        set.add(two);
+        assertThat(set, hasSize(1));
+        assertThat(one, is(two));
+        assertThat(one.hashCode(), is(two.hashCode()));
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java
new file mode 100644
index 0000000..b246022
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphDataTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.shenyu.common.dto;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Test case for {@link GraphData}.
+ */
+public class GraphDataTest {
+
+    @Test
+    public void testGetterSetter() {
+        final GraphData graph = new GraphData();
+        graph.setSchemaVersion(1);
+        graph.setNamespaceId("ns");
+        graph.setDeploymentBinding("deploy");
+        graph.setGraphId("g");
+        graph.setRevision(9L);
+        graph.setContentHash("hash");
+        graph.setEntryNodeId("n0");
+        graph.setNodes(Collections.singletonList(new GraphNodeData()));
+
+        assertThat(graph.getSchemaVersion(), is(1));
+        assertThat(graph.getNamespaceId(), is("ns"));
+        assertThat(graph.getDeploymentBinding(), is("deploy"));
+        assertThat(graph.getGraphId(), is("g"));
+        assertThat(graph.getRevision(), is(9L));
+        assertThat(graph.getContentHash(), is("hash"));
+        assertThat(graph.getEntryNodeId(), is("n0"));
+        assertThat(graph.getNodes(), hasSize(1));
+    }
+
+    @Test
+    public void testEqualsAndHashCode() {
+        final GraphData one = sample();
+        final GraphData two = sample();
+        final Set<GraphData> set = new HashSet<>();
+        set.add(one);
+        set.add(two);
+        assertThat(set, hasSize(1));
+        assertThat(one, is(two));
+        assertThat(one.hashCode(), is(two.hashCode()));
+    }
+
+    @Test
+    public void testRevisionAffectsEquality() {
+        final GraphData one = sample();
+        final GraphData two = sample();
+        two.setRevision(2L);
+        assertThat(one.equals(two), is(false));
+    }
+
+    private GraphData sample() {
+        final GraphData graph = new GraphData();
+        graph.setSchemaVersion(1);
+        graph.setNamespaceId("ns");
+        graph.setGraphId("g");
+        graph.setRevision(1L);
+        graph.setContentHash("hash");
+        graph.setEntryNodeId("n0");
+        graph.setNodes(Collections.emptyList());
+        return graph;
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java
new file mode 100644
index 0000000..43435f9
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/GraphNodeDataTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.shenyu.common.dto;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Test case for {@link GraphNodeData}.
+ */
+public class GraphNodeDataTest {
+
+    @Test
+    public void testGetterSetter() {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId("n0");
+        node.setType("PLUGIN");
+        node.setPluginName("global");
+        node.setNext("n1");
+        node.setConditions(Collections.singletonList(new GraphBranchData()));
+        node.setDefaultTarget("n2");
+        node.setRequired(true);
+
+        assertThat(node.getNodeId(), is("n0"));
+        assertThat(node.getType(), is("PLUGIN"));
+        assertThat(node.getPluginName(), is("global"));
+        assertThat(node.getNext(), is("n1"));
+        assertThat(node.getConditions(), hasSize(1));
+        assertThat(node.getDefaultTarget(), is("n2"));
+        assertThat(node.isRequired(), is(true));
+    }
+
+    @Test
+    public void testEqualsAndHashCode() {
+        final GraphNodeData one = sample();
+        final GraphNodeData two = sample();
+        final Set<GraphNodeData> set = new HashSet<>();
+        set.add(one);
+        set.add(two);
+        assertThat(set, hasSize(1));
+        assertThat(one, is(two));
+        assertThat(one.hashCode(), is(two.hashCode()));
+    }
+
+    @Test
+    public void testRequiredAffectsEquality() {
+        final GraphNodeData one = sample();
+        final GraphNodeData two = sample();
+        two.setRequired(true);
+        assertThat(one.equals(two), is(false));
+    }
+
+    private GraphNodeData sample() {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId("n0");
+        node.setType("PLUGIN");
+        node.setPluginName("global");
+        node.setNext("n1");
+        return node;
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java
new file mode 100644
index 0000000..488981e
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/enums/GraphNodeTypeEnumTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.shenyu.common.enums;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Test case for {@link GraphNodeTypeEnum}.
+ */
+public class GraphNodeTypeEnumTest {
+
+    @Test
+    public void testGetName() {
+        assertThat(GraphNodeTypeEnum.PLUGIN.getName(), is("PLUGIN"));
+        assertThat(GraphNodeTypeEnum.CHOICE.getName(), is("CHOICE"));
+        assertThat(GraphNodeTypeEnum.END.getName(), is("END"));
+    }
+
+    @Test
+    public void testFromName() {
+        assertThat(GraphNodeTypeEnum.fromName("PLUGIN").orElse(null), is(GraphNodeTypeEnum.PLUGIN));
+        assertThat(GraphNodeTypeEnum.fromName("CHOICE").orElse(null), is(GraphNodeTypeEnum.CHOICE));
+        assertThat(GraphNodeTypeEnum.fromName("END").orElse(null), is(GraphNodeTypeEnum.END));
+        assertThat(GraphNodeTypeEnum.fromName("SWITCH").isPresent(), is(false));
+        assertThat(GraphNodeTypeEnum.fromName(null).isPresent(), is(false));
+    }
+
+    @Test
+    public void testIsValidName() {
+        assertThat(GraphNodeTypeEnum.isValidName("PLUGIN"), is(true));
+        assertThat(GraphNodeTypeEnum.isValidName("plugin"), is(false));
+        assertThat(GraphNodeTypeEnum.isValidName(null), is(false));
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java
new file mode 100644
index 0000000..a59788e
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphCompilerTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Test case for {@link GraphCompiler} and the immutability of {@link GraphPlan}.
+ */
+public class GraphCompilerTest {
+
+    private static final Set<String> PLUGINS = Set.of("global", "divide", "grpc", "response", "mock");
+
+    private final GraphCompiler compiler = new GraphCompiler(new GraphValidator(PLUGINS));
+
+    @Test
+    public void testCompileLinearPlanResolvesSuccessors() {
+        final GraphPlan plan = compiler.compile(linearGraph());
+        assertThat(plan.getGraphId(), is("g-test"));
+        assertThat(plan.getNamespaceId(), is("ns-test"));
+        assertThat(plan.getRevision(), is(1L));
+        assertThat(plan.getEntryNodeId(), is("n0"));
+        assertThat(plan.size(), is(4));
+        assertThat(plan.getSuccessors("n0"), contains("n1"));
+        assertThat(plan.getSuccessors("n2"), contains("end"));
+        assertThat(plan.getSuccessors("end").isEmpty(), is(true));
+        assertThat(plan.getNode("n0").getPluginName(), is("global"));
+        assertThat(plan.getNode("ghost"), is(nullValue()));
+    }
+
+    @Test
+    public void testCompileChoicePlanResolvesBranchThenDefault() {
+        final GraphPlan plan = compiler.compile(choiceGraph());
+        assertThat(plan.getSuccessors("c"), contains("n1", "n2"));
+    }
+
+    @Test
+    public void testCompileInvalidGraphThrows() {
+        final GraphData graph = linearGraph();
+        graph.setEntryNodeId("ghost");
+        assertThrows(GraphValidationException.class, () -> compiler.compile(graph));
+    }
+
+    @Test
+    public void testPlanNodeIdsAreImmutable() {
+        final GraphPlan plan = compiler.compile(linearGraph());
+        assertThrows(UnsupportedOperationException.class, () -> plan.nodeIds().add("x"));
+    }
+
+    @Test
+    public void testPlanSuccessorsAreImmutable() {
+        final GraphPlan plan = compiler.compile(linearGraph());
+        assertThrows(UnsupportedOperationException.class, () -> plan.getSuccessors("n0").add("x"));
+    }
+
+    @Test
+    public void testEqualGraphsCompileToEqualPlans() {
+        assertThat(compiler.compile(linearGraph()), is(compiler.compile(linearGraph())));
+    }
+
+    private GraphData linearGraph() {
+        return graph("n0",
+                plugin("n0", "global", "n1"),
+                plugin("n1", "divide", "n2"),
+                plugin("n2", "response", "end"),
+                end("end"));
+    }
+
+    private GraphData choiceGraph() {
+        final GraphNodeData choice = choice("c", "n2", branch("n1", condition("req_method", "grpc")));
+        return graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "grpc", "n2"),
+                plugin("n2", "response", "end"),
+                end("end"));
+    }
+
+    private GraphData graph(final String entry, final GraphNodeData... nodes) {
+        final GraphData graph = new GraphData();
+        graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION);
+        graph.setNamespaceId("ns-test");
+        graph.setGraphId("g-test");
+        graph.setRevision(1L);
+        graph.setContentHash("hash");
+        graph.setEntryNodeId(entry);
+        graph.setNodes(new ArrayList<>(Arrays.asList(nodes)));
+        return graph;
+    }
+
+    private GraphNodeData plugin(final String id, final String pluginName, final String next) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.PLUGIN.getName());
+        node.setPluginName(pluginName);
+        node.setNext(next);
+        return node;
+    }
+
+    private GraphNodeData end(final String id) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.END.getName());
+        return node;
+    }
+
+    private GraphNodeData choice(final String id, final String defaultTarget, final GraphBranchData... branches) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.CHOICE.getName());
+        node.setDefaultTarget(defaultTarget);
+        node.setConditions(new ArrayList<>(Arrays.asList(branches)));
+        return node;
+    }
+
+    private GraphBranchData branch(final String target, final ConditionData... conditions) {
+        return new GraphBranchData(new ArrayList<>(Arrays.asList(conditions)), 0, target);
+    }
+
+    private ConditionData condition(final String paramType, final String paramValue) {
+        final ConditionData data = new ConditionData();
+        data.setParamType(paramType);
+        data.setOperator("match");
+        data.setParamValue(paramValue);
+        return data;
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java
new file mode 100644
index 0000000..7a3564d
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphTopologyTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Test case for {@link GraphTopology}.
+ */
+public class GraphTopologyTest {
+
+    @Test
+    public void testIsBlank() {
+        assertThat(GraphTopology.isBlank(null), is(true));
+        assertThat(GraphTopology.isBlank("  "), is(true));
+        assertThat(GraphTopology.isBlank("x"), is(false));
+    }
+
+    @Test
+    public void testNodeTypePredicates() {
+        assertThat(GraphTopology.isPlugin(node(GraphNodeTypeEnum.PLUGIN)), is(true));
+        assertThat(GraphTopology.isChoice(node(GraphNodeTypeEnum.CHOICE)), is(true));
+        assertThat(GraphTopology.isEnd(node(GraphNodeTypeEnum.END)), is(true));
+        assertThat(GraphTopology.isPlugin(null), is(false));
+        assertThat(GraphTopology.isPlugin(node(GraphNodeTypeEnum.END)), is(false));
+    }
+
+    @Test
+    public void testSuccessorsOfPlugin() {
+        final GraphNodeData plugin = node(GraphNodeTypeEnum.PLUGIN);
+        plugin.setNext("n1");
+        assertThat(GraphTopology.successorsOf(plugin), contains("n1"));
+
+        final GraphNodeData noNext = node(GraphNodeTypeEnum.PLUGIN);
+        assertThat(GraphTopology.successorsOf(noNext), is(empty()));
+    }
+
+    @Test
+    public void testSuccessorsOfEnd() {
+        assertThat(GraphTopology.successorsOf(node(GraphNodeTypeEnum.END)), is(empty()));
+    }
+
+    @Test
+    public void testSuccessorsOfChoiceAppendsDistinctDefault() {
+        final GraphNodeData choice = node(GraphNodeTypeEnum.CHOICE);
+        choice.setConditions(Arrays.asList(branch("n1"), branch("n2")));
+        choice.setDefaultTarget("n3");
+        assertThat(GraphTopology.successorsOf(choice), contains("n1", "n2", "n3"));
+    }
+
+    @Test
+    public void testSuccessorsOfChoiceDoesNotDuplicateDefault() {
+        final GraphNodeData choice = node(GraphNodeTypeEnum.CHOICE);
+        choice.setConditions(Collections.singletonList(branch("n1")));
+        choice.setDefaultTarget("n1");
+        assertThat(GraphTopology.successorsOf(choice), contains("n1"));
+    }
+
+    private GraphNodeData node(final GraphNodeTypeEnum type) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId("id");
+        node.setType(type.getName());
+        return node;
+    }
+
+    private GraphBranchData branch(final String target) {
+        return new GraphBranchData(Collections.emptyList(), 0, target);
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java
new file mode 100644
index 0000000..1728b39
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/GraphValidatorTest.java
@@ -0,0 +1,366 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.GraphBranchData;
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Test case for {@link GraphValidator}.
+ */
+public class GraphValidatorTest {
+
+    private static final Set<String> PLUGINS = Set.of(
+            "global", "sign", "divide", "response", "grpc", "mock", "rewrite", "uri");
+
+    private final GraphValidator validator = new GraphValidator(PLUGINS);
+
+    @Test
+    public void testNullGraphRejected() {
+        assertThat(validator.validate(null), hasItem(containsString("must not be null")));
+    }
+
+    @Test
+    public void testValidLinearGraph() {
+        final GraphData graph = linearGraph();
+        assertThat(validator.validate(graph), is(empty()));
+    }
+
+    @Test
+    public void testValidChoiceGraph() {
+        final GraphData graph = choiceGraph();
+        assertThat(validator.validate(graph), is(empty()));
+    }
+
+    @Test
+    public void testUnsupportedSchemaVersion() {
+        final GraphData graph = linearGraph();
+        graph.setSchemaVersion(2);
+        assertThat(validator.validate(graph), hasItem(containsString("unsupported schemaVersion")));
+    }
+
+    @Test
+    public void testBlankIdentityRejected() {
+        final GraphData graph = linearGraph();
+        graph.setGraphId(" ");
+        graph.setNamespaceId(null);
+        graph.setEntryNodeId("");
+        final List<String> violations = validator.validate(graph);
+        assertThat(violations, hasItem(containsString("graphId must not be blank")));
+        assertThat(violations, hasItem(containsString("namespaceId must not be blank")));
+        assertThat(violations, hasItem(containsString("entryNodeId must not be blank")));
+    }
+
+    @Test
+    public void testEmptyNodesRejected() {
+        final GraphData graph = linearGraph();
+        graph.setNodes(Collections.emptyList());
+        assertThat(validator.validate(graph), hasItem(containsString("nodes must not be empty")));
+    }
+
+    @Test
+    public void testDuplicateNodeIdRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "n1"),
+                plugin("n0", "sign", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("duplicate nodeId 'n0'")));
+    }
+
+    @Test
+    public void testUnknownNodeTypeRejected() {
+        final GraphNodeData bad = new GraphNodeData();
+        bad.setNodeId("n1");
+        bad.setType("SWITCH");
+        final GraphData graph = graph("n0", plugin("n0", "global", "n1"), bad, end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("unknown type 'SWITCH'")));
+    }
+
+    @Test
+    public void testEntryReferencesMissingNode() {
+        final GraphData graph = linearGraph();
+        graph.setEntryNodeId("ghost");
+        assertThat(validator.validate(graph), hasItem(containsString("entryNodeId 'ghost' does not reference a node")));
+    }
+
+    @Test
+    public void testDanglingPluginNextRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "ghost"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("successor 'next' -> unknown node 'ghost'")));
+    }
+
+    @Test
+    public void testChoiceWithoutDefaultTargetRejected() {
+        final GraphNodeData choice = choice("c", null, branch("n1", condition("req_method", "GET")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("must declare a defaultTarget")));
+    }
+
+    @Test
+    public void testChoiceBranchUnknownTargetRejected() {
+        final GraphNodeData choice = choice("c", "n1", branch("ghost", condition("req_method", "GET")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("branch target 'ghost' -> unknown node")));
+    }
+
+    @Test
+    public void testDuplicateBranchesRejected() {
+        final GraphNodeData choice = choice("c", "n1",
+                branch("n1", condition("req_method", "GET")),
+                branch("n1", condition("req_method", "GET")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("duplicate branch")));
+    }
+
+    @Test
+    public void testCycleRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "n1"),
+                plugin("n1", "divide", "n0"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("contains a cycle")));
+    }
+
+    @Test
+    public void testUnreachableNodeRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "end"),
+                plugin("orphan", "mock", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("node 'orphan' is not reachable")));
+    }
+
+    @Test
+    public void testNoPathToEndRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "n1"),
+                plugin("n1", "divide", null));
+        final List<String> violations = validator.validate(graph);
+        assertThat(violations, hasItem(containsString("must have exactly one successor")));
+    }
+
+    @Test
+    public void testPluginWithoutNameRejected() {
+        final GraphData graph = graph("n0",
+                plugin("n0", null, "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("must bind a pluginName")));
+    }
+
+    @Test
+    public void testEndNodeWithNextRejected() {
+        final GraphNodeData badEnd = end("end");
+        badEnd.setNext("n0");
+        final GraphData graph = graph("n0", plugin("n0", "global", "end"), badEnd);
+        assertThat(validator.validate(graph), hasItem(containsString("END node 'end' must not declare a successor")));
+    }
+
+    @Test
+    public void testRequiredPluginMissingFromCatalogFailsClosed() {
+        final GraphNodeData required = plugin("n0", "global", "end");
+        required.setRequired(true);
+        final GraphData graph = graph("n0", required, end("end"));
+        final GraphValidator emptyCatalog = new GraphValidator(Set.of());
+        assertThat(emptyCatalog.validate(graph), hasItem(containsString("required PLUGIN node 'n0' binds plugin 'global' which is not installed")));
+    }
+
+    @Test
+    public void testOptionalPluginMissingFromCatalogRejected() {
+        final GraphData graph = graph("n0", plugin("n0", "unknownPlugin", "end"), end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("which is not installed")));
+    }
+
+    @Test
+    public void testDuplicatePluginOnPathRejected() {
+        final GraphNodeData choice = choice("c", "n2",
+                branch("n1", condition("req_method", "GET")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "divide", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                plugin("n2", "mock", "end"),
+                end("end"));
+        assertThat(validator.validate(graph), hasItem(containsString("executes plugin 'divide' more than once")));
+    }
+
+    @Test
+    public void testMaxNodesLimit() {
+        final GraphValidator small = new GraphValidator(limits(2, 16, 32, 64), PLUGINS);
+        assertThat(small.validate(linearGraph()), hasItem(containsString("exceeding max 2")));
+    }
+
+    @Test
+    public void testMaxOutDegreeLimit() {
+        final GraphValidator small = new GraphValidator(limits(128, 1, 32, 64), PLUGINS);
+        final GraphNodeData choice = choice("c", "n3",
+                branch("n1", condition("req_method", "GET")),
+                branch("n2", condition("req_method", "POST")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                plugin("n2", "mock", "end"),
+                plugin("n3", "uri", "end"),
+                end("end"));
+        assertThat(small.validate(graph), hasItem(containsString("exceeding max out-degree 1")));
+    }
+
+    @Test
+    public void testMaxConditionsPerBranchLimit() {
+        final GraphValidator small = new GraphValidator(limits(128, 16, 1, 64), PLUGINS);
+        final GraphNodeData choice = choice("c", "n1",
+                branch("n1", condition("req_method", "GET"), condition("uri", "/x")));
+        final GraphData graph = graph("n0",
+                plugin("n0", "global", "c"),
+                choice,
+                plugin("n1", "divide", "end"),
+                end("end"));
+        assertThat(small.validate(graph), hasItem(containsString("exceeding max 1")));
+    }
+
+    @Test
+    public void testMaxPathLengthLimit() {
+        final GraphValidator small = new GraphValidator(limits(128, 16, 32, 2), PLUGINS);
+        assertThat(small.validate(linearGraph()), hasItem(containsString("exceeding max 2")));
+    }
+
+    @Test
+    public void testValidateOrThrowRaises() {
+        final GraphData graph = linearGraph();
+        graph.setEntryNodeId("ghost");
+        final GraphValidationException ex = assertThrows(GraphValidationException.class, () -> validator.validateOrThrow(graph));
+        assertThat(ex.getViolations(), hasItem(containsString("does not reference a node")));
+    }
+
+    @Test
+    public void testValidateOrThrowPassesForValidGraph() {
+        validator.validateOrThrow(linearGraph());
+        validator.validateOrThrow(choiceGraph());
+    }
+
+    private GraphData linearGraph() {
+        return graph("n0",
+                plugin("n0", "global", "n1", true),
+                plugin("n1", "sign", "n2", true),
+                plugin("n2", "divide", "n3"),
+                plugin("n3", "response", "end"),
+                end("end"));
+    }
+
+    private GraphData choiceGraph() {
+        final GraphNodeData choice = choice("c", "n2", branch("n1", condition("req_method", "grpc")));
+        return graph("n0",
+                plugin("n0", "global", "c", true),
+                choice,
+                plugin("n1", "grpc", "n3"),
+                plugin("n2", "divide", "n3"),
+                plugin("n3", "response", "end"),
+                end("end"));
+    }
+
+    private GraphData graph(final String entry, final GraphNodeData... nodes) {
+        final GraphData graph = new GraphData();
+        graph.setSchemaVersion(GraphValidator.SUPPORTED_SCHEMA_VERSION);
+        graph.setNamespaceId("ns-test");
+        graph.setGraphId("g-test");
+        graph.setRevision(1L);
+        graph.setContentHash("hash");
+        graph.setEntryNodeId(entry);
+        graph.setNodes(new ArrayList<>(Arrays.asList(nodes)));
+        return graph;
+    }
+
+    private GraphNodeData plugin(final String id, final String pluginName, final String next) {
+        return plugin(id, pluginName, next, false);
+    }
+
+    private GraphNodeData plugin(final String id, final String pluginName, final String next, final boolean required) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.PLUGIN.getName());
+        node.setPluginName(pluginName);
+        node.setNext(next);
+        node.setRequired(required);
+        return node;
+    }
+
+    private GraphNodeData end(final String id) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.END.getName());
+        return node;
+    }
+
+    private GraphNodeData choice(final String id, final String defaultTarget, final GraphBranchData... branches) {
+        final GraphNodeData node = new GraphNodeData();
+        node.setNodeId(id);
+        node.setType(GraphNodeTypeEnum.CHOICE.getName());
+        node.setDefaultTarget(defaultTarget);
+        node.setConditions(new ArrayList<>(Arrays.asList(branches)));
+        return node;
+    }
+
+    private GraphBranchData branch(final String target, final ConditionData... conditions) {
+        return new GraphBranchData(new ArrayList<>(Arrays.asList(conditions)), 0, target);
+    }
+
+    private ConditionData condition(final String paramType, final String paramValue) {
+        final ConditionData data = new ConditionData();
+        data.setParamType(paramType);
+        data.setOperator("match");
+        data.setParamValue(paramValue);
+        return data;
+    }
+
+    private GraphLimits limits(final int maxNodes, final int maxOutDegree, final int maxConditions, final int maxPathLength) {
+        return new GraphLimits(maxNodes, maxOutDegree, maxConditions, maxPathLength,
+                GraphLimits.DEFAULT_MAX_FORK_RECURSION_DEPTH, GraphLimits.DEFAULT_MAX_RETAINED_REVISIONS,
+                GraphLimits.DEFAULT_MAX_GRAPHS_PER_NAMESPACE);
+    }
+}
diff --git a/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java
new file mode 100644
index 0000000..bdb79c8
--- /dev/null
+++ b/shenyu-common/src/test/java/org/apache/shenyu/common/graph/LegacyChainImporterTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.shenyu.common.graph;
+
+import org.apache.shenyu.common.dto.GraphData;
+import org.apache.shenyu.common.dto.GraphNodeData;
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.enums.GraphNodeTypeEnum;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+
+/**
+ * Test case for {@link LegacyChainImporter}.
+ */
+public class LegacyChainImporterTest {
+
+    private static final Set<String> PLUGINS = Set.of("global", "sign", "divide", "response");
+
+    private final GraphValidator validator = new GraphValidator(PLUGINS);
+
+    @Test
+    public void testImportReproducesLegacySortOrder() {
+        final List<PluginData> plugins = Arrays.asList(
+                plugin("divide", 200, true),
+                plugin("global", -1, true),
+                plugin("response", 420, true),
+                plugin("sign", 20, true));
+        final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 7L);
+
+        assertThat(graph.getSchemaVersion(), is(GraphValidator.SUPPORTED_SCHEMA_VERSION));
+        assertThat(graph.getNamespaceId(), is("ns-test"));
+        assertThat(graph.getGraphId(), is("g-import"));
+        assertThat(graph.getRevision(), is(7L));
+        assertThat(graph.getEntryNodeId(), is("n0"));
+
+        final List<GraphNodeData> nodes = graph.getNodes();
+        assertThat(nodes.size(), is(5));
+        assertThat(nodes.get(0).getPluginName(), is("global"));
+        assertThat(nodes.get(0).isRequired(), is(true));
+        assertThat(nodes.get(0).getNext(), is("n1"));
+        assertThat(nodes.get(1).getPluginName(), is("sign"));
+        assertThat(nodes.get(1).isRequired(), is(true));
+        assertThat(nodes.get(2).getPluginName(), is("divide"));
+        assertThat(nodes.get(2).isRequired(), is(false));
+        assertThat(nodes.get(3).getPluginName(), is("response"));
+        assertThat(nodes.get(3).getNext(), is(LegacyChainImporter.END_NODE_ID));
+        assertThat(nodes.get(4).getType(), is(GraphNodeTypeEnum.END.getName()));
+    }
+
+    @Test
+    public void testImportSkipsDisabledPlugins() {
+        final List<PluginData> plugins = Arrays.asList(
+                plugin("global", -1, true),
+                plugin("sign", 20, false),
+                plugin("divide", 200, true));
+        final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L);
+        assertThat(graph.getNodes().size(), is(3));
+        assertThat(graph.getNodes().get(1).getPluginName(), is("divide"));
+    }
+
+    @Test
+    public void testImportEmptyChainYieldsSingleEnd() {
+        final GraphData graph = LegacyChainImporter.importLinearChain(Collections.emptyList(), "ns-test", "g-empty", 1L);
+        assertThat(graph.getEntryNodeId(), is(LegacyChainImporter.END_NODE_ID));
+        assertThat(graph.getNodes().size(), is(1));
+        assertThat(graph.getNodes().get(0).getType(), is(GraphNodeTypeEnum.END.getName()));
+        assertThat(validator.validate(graph), is(empty()));
+    }
+
+    @Test
+    public void testImportedGraphPassesValidationAndCompiles() {
+        final List<PluginData> plugins = Arrays.asList(
+                plugin("global", -1, true),
+                plugin("sign", 20, true),
+                plugin("divide", 200, true),
+                plugin("response", 420, true));
+        final GraphData graph = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L);
+        assertThat(validator.validate(graph), is(empty()));
+        final GraphPlan plan = new GraphCompiler(validator).compile(graph);
+        assertThat(plan.getEntryNodeId(), is("n0"));
+        assertThat(plan.getSuccessors("n3"), is(Collections.singletonList(LegacyChainImporter.END_NODE_ID)));
+    }
+
+    @Test
+    public void testContentHashIsDeterministicAndContentSensitive() {
+        final List<PluginData> plugins = Arrays.asList(plugin("global", -1, true), plugin("divide", 200, true));
+        final GraphData first = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L);
+        final GraphData second = LegacyChainImporter.importLinearChain(plugins, "ns-test", "g-import", 1L);
+        assertThat(first.getContentHash(), is(second.getContentHash()));
+
+        final GraphData changed = LegacyChainImporter.importLinearChain(
+                Arrays.asList(plugin("global", -1, true), plugin("response", 420, true)), "ns-test", "g-import", 1L);
+        assertThat(changed.getContentHash(), is(not(first.getContentHash())));
+    }
+
+    private PluginData plugin(final String name, final int sort, final boolean enabled) {
+        return PluginData.builder().id(name).name(name).role("0").enabled(enabled).sort(sort).build();
+    }
+}